From 7d9f59e51bdc0e7acddb65d614a6eec65087e301 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 17:17:47 -0700 Subject: [PATCH] feat(billing,bank): capture + void web UI (plan phase 5 web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes phase 5 — the ledger and chequera pages get the append+void UI on top of the phase-5 API. Web: - Shared MovementForm (customer picker + línea + cargo/abono sign + amount + moneda + concepto facet + periodo/referencia/cheque/mensaje). Used by both /estado-cuenta (cross-customer, picker) and /estado-cuenta/[id] (customer prefilled). - /estado-cuenta and /estado-cuenta/[id]: "Capturar movimiento" toggle gated ledger:create; per-row "Anular" gated ledger:void; voided rows struck-through. Save/void refresh the list + stats. - /banco: inline BankCaptureForm (ingreso/egreso sign, cheque, operado, transferencia, monto en letras) gated bank:create; per-row "Anular" gated bank:void; voided rows struck-through. - api.ts: createMovement/voidMovement, createBankMovement/voidBankMovement; CreateMovementInput/CreateBankMovementInput types; `voided` on the movement/statement/bank list items. Also: lookups.controller.ts now audit-logs provider/policy-type/adjuster create/update/delete (parity with the other write controllers). API + web compile clean. This is the last piece of the feat/crud-rbac branch — all five sections plus users are now full CRUD with role gating. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/policies/lookups.controller.ts | 98 +++++-- apps/web/src/app/banco/page.tsx | 288 ++++++++++++++++++- apps/web/src/app/estado-cuenta/[id]/page.tsx | 128 ++++++++- apps/web/src/app/estado-cuenta/page.tsx | 91 +++++- apps/web/src/components/MovementForm.tsx | 260 +++++++++++++++++ apps/web/src/lib/api.ts | 35 +++ apps/web/src/lib/types.ts | 33 +++ 7 files changed, 897 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/components/MovementForm.tsx diff --git a/apps/api/src/policies/lookups.controller.ts b/apps/api/src/policies/lookups.controller.ts index 9ca29a9..7a31e55 100644 --- a/apps/api/src/policies/lookups.controller.ts +++ b/apps/api/src/policies/lookups.controller.ts @@ -6,11 +6,14 @@ import { Param, Patch, Post, + Req, UseGuards, } from "@nestjs/common"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { PoliciesService } from "./policies.service"; import { AdjusterDto, @@ -29,7 +32,14 @@ import { @UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("lookups") export class LookupsController { - constructor(private readonly policies: PoliciesService) {} + constructor( + private readonly policies: PoliciesService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get() list() { @@ -38,49 +48,99 @@ export class LookupsController { @Post("providers") @RequireAbility("lookup:manage") - createProvider(@Body() dto: ProviderDto) { - return this.policies.createProvider(dto); + async createProvider(@Body() dto: ProviderDto, @Req() req: Request) { + const row = await this.policies.createProvider(dto); + void this.audit.log(this.actingId(req), "lookup.provider.create", { + providerId: row.id, + name: row.name, + }); + return row; } @Patch("providers/:id") @RequireAbility("lookup:manage") - updateProvider(@Param("id") id: string, @Body() dto: UpdateProviderDto) { - return this.policies.updateProvider(id, dto); + async updateProvider( + @Param("id") id: string, + @Body() dto: UpdateProviderDto, + @Req() req: Request, + ) { + const row = await this.policies.updateProvider(id, dto); + void this.audit.log(this.actingId(req), "lookup.provider.update", { + providerId: id, + }); + return row; } @Delete("providers/:id") @RequireAbility("lookup:manage") - removeProvider(@Param("id") id: string) { - return this.policies.removeProvider(id); + async removeProvider(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removeProvider(id); + void this.audit.log(this.actingId(req), "lookup.provider.delete", { + providerId: id, + }); + return row; } @Post("policy-types") @RequireAbility("lookup:manage") - createType(@Body() dto: PolicyTypeDto) { - return this.policies.createPolicyType(dto); + async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) { + const row = await this.policies.createPolicyType(dto); + void this.audit.log(this.actingId(req), "lookup.policyType.create", { + policyTypeId: row.id, + name: row.name, + }); + return row; } @Patch("policy-types/:id") @RequireAbility("lookup:manage") - updateType(@Param("id") id: string, @Body() dto: UpdatePolicyTypeDto) { - return this.policies.updatePolicyType(id, dto); + async updateType( + @Param("id") id: string, + @Body() dto: UpdatePolicyTypeDto, + @Req() req: Request, + ) { + const row = await this.policies.updatePolicyType(id, dto); + void this.audit.log(this.actingId(req), "lookup.policyType.update", { + policyTypeId: id, + }); + return row; } @Delete("policy-types/:id") @RequireAbility("lookup:manage") - removeType(@Param("id") id: string) { - return this.policies.removePolicyType(id); + async removeType(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removePolicyType(id); + void this.audit.log(this.actingId(req), "lookup.policyType.delete", { + policyTypeId: id, + }); + return row; } @Post("adjusters") @RequireAbility("lookup:manage") - createAdjuster(@Body() dto: AdjusterDto) { - return this.policies.createAdjuster(dto); + async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) { + const row = await this.policies.createAdjuster(dto); + void this.audit.log(this.actingId(req), "lookup.adjuster.create", { + adjusterId: row.id, + }); + return row; } @Patch("adjusters/:id") @RequireAbility("lookup:manage") - updateAdjuster(@Param("id") id: string, @Body() dto: UpdateAdjusterDto) { - return this.policies.updateAdjuster(id, dto); + async updateAdjuster( + @Param("id") id: string, + @Body() dto: UpdateAdjusterDto, + @Req() req: Request, + ) { + const row = await this.policies.updateAdjuster(id, dto); + void this.audit.log(this.actingId(req), "lookup.adjuster.update", { + adjusterId: id, + }); + return row; } @Delete("adjusters/:id") @RequireAbility("lookup:manage") - removeAdjuster(@Param("id") id: string) { - return this.policies.removeAdjuster(id); + async removeAdjuster(@Param("id") id: string, @Req() req: Request) { + const row = await this.policies.removeAdjuster(id); + void this.audit.log(this.actingId(req), "lookup.adjuster.delete", { + adjusterId: id, + }); + return row; } } diff --git a/apps/web/src/app/banco/page.tsx b/apps/web/src/app/banco/page.tsx index ff2794f..83028fb 100644 --- a/apps/web/src/app/banco/page.tsx +++ b/apps/web/src/app/banco/page.tsx @@ -3,11 +3,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { + createBankMovement, getBankFacets, getBankStats, getBankSummary, listBankMovements, + voidBankMovement, } from "@/lib/api"; +import { useCan } from "@/lib/abilities"; import { bankDirectionLabel, bankSourceLabel, @@ -27,6 +30,7 @@ import type { BankStats, BankSummary, BankTotals, + CreateBankMovementInput, } from "@/lib/types"; /** @@ -77,6 +81,8 @@ export default function BancoPage() { } function BankBrowser() { + const canCapture = useCan("bank:create"); + const canVoid = useCan("bank:void"); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); const [view, setView] = useState("movimientos"); @@ -93,6 +99,7 @@ function BankBrowser() { const [summaryYear, setSummaryYear] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [captureOpen, setCaptureOpen] = useState(false); const debounceRef = useRef>(); @@ -237,8 +244,28 @@ function BankBrowser() { ))} + {view === "movimientos" && canCapture && ( + + )} + {view === "movimientos" && captureOpen && ( + { + setCaptureOpen(false); + runSearch(movements?.page ?? 1); + getBankStats().then(setStats).catch(() => setStats(null)); + }} + onCancel={() => setCaptureOpen(false)} + /> + )} + {view === "movimientos" && (