From 7df928c3abfbcac53d32589d27cf21fd844d32f4 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Mon, 27 Jul 2026 21:54:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(billing):=20receipt=20capture=20=E2=80=94?= =?UTF-8?q?=20outstanding=20workflow,=20batch=20by=20check,=20reconciliati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor" replacement, on top of the single-movement capture from plan step 6. No new abilities: batching and resolving are both capturing. - outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and POST /billing/:id/resolve-outstanding (gated ledger:create, not ledger:void — resolving completes a capture rather than reversing one). Outstanding rows are excluded from every balance aggregate, matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but still count in the movement browser's filtered totals. - POST /billing/batch: many customers' receipts against one check, in one $transaction. Deliberately not a persisted batch entity — checkNumber is already a column and grouping by it answers every legacy by-check query. - GET /billing/by-check + a cheque-count report, replacing REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print, PDF, CSV and XLSX come free from the existing /reportes/:slug machinery. - Web: /estado-cuenta/lote (the Editor screen, with live reconciliation against the physical check amount), an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura" nav entry. Integration seam for the OCR auto-capture module (spec §2), which is required to post through createBatch rather than writing Transaction rows itself: items[i] maps to lines[i] so postedTransactionId can be zipped back on; opts.refs[i] stamps captureRef with a duplicate-post guard that a voided row deliberately does not block; opts.source is service-level only, so an HTTP client cannot label hand-keyed rows as machine-captured. captureSource/captureRef are nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. Fixes two pre-existing bugs found while building this: - statement() filtered legacySourceTable with `notIn`, which compiles to SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured movement was invisible on the customer statement (438 rows in the movement browser vs 392 on the statement) while showing everywhere else. This would have made the whole capture feature look broken. - The balances count query omitted the void filter its own page query applied, so the total disagreed with the rows. Nav highlighting now resolves by longest match; the previous first-startsWith logic lit up both the parent and any nested entry. Verified end-to-end against the dev DB, API and browser; all test rows removed afterwards. Also corrects RESUME.md, which documented the dev ports as :3001/:3000 — they are :4501/:4500, from the env files. Co-Authored-By: Claude Opus 5 --- PLAN.md | 32 +- RESUME.md | 9 +- apps/api/src/billing/billing.controller.ts | 64 ++- apps/api/src/billing/billing.service.ts | 297 +++++++++- apps/api/src/billing/movement.dto.ts | 58 ++ apps/api/src/reports/reports.registry.ts | 106 ++++ apps/web/src/app/estado-cuenta/lote/page.tsx | 557 +++++++++++++++++++ apps/web/src/app/estado-cuenta/page.tsx | 177 +++++- apps/web/src/app/globals.css | 8 + apps/web/src/components/AppShell.tsx | 29 +- apps/web/src/components/MovementForm.tsx | 31 ++ apps/web/src/lib/api.ts | 40 ++ apps/web/src/lib/types.ts | 70 +++ packages/database/prisma/schema.prisma | 28 + 14 files changed, 1476 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/app/estado-cuenta/lote/page.tsx diff --git a/PLAN.md b/PLAN.md index e43d4de..347d0d4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -131,12 +131,23 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult 9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set. 10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly. 11. **Receipt capture ("Editor") completion + three net-new ops features — NOT STARTED, spec written.** Full design in [`docs/RECEIPT_CAPTURE_SPEC.md`](docs/RECEIPT_CAPTURE_SPEC.md), from the 2026-07-25/26 meeting with Jorge: - - **Receipt capture module** — the legacy "Editor" replacement: wires up the already-existing but unused `Transaction.outstanding` field (NOPAGO workflow), adds batch-by-check capture, and a check-reconciliation view replacing `REPORTE CHEQUE COUNT`. Builds directly on the single-movement capture already shipped in `billing/` (step 6) — smallest piece, do first. + - **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser. + **Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows. + **OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. - **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all. - **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`. - **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s). Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section. +12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11: + - **Renewal notification emails** — a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first. + - **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike. + - **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate. + - **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.) + + **Two pre-existing defects were found while verifying this spec and should be fixed as part of the liquidación work:** (a) `policy_types` is missing its `INCENDIO` and `M_EMPR` rows and, because `policies_policyTypeId_fkey` is `ON DELETE SET NULL`, 5 `m_empr` policies silently lost their ramo — 4 of them are pending liquidación and are invisible to every ramo-filtered query; (b) the legacy settlement slots don't match what the target model assumed — `MULT`/`INCENDIO` carry two and `M EMPR` carries four, while `Policy` collapses to one, so ≤41 MULT second settlements were dropped in migration. Spec recommends moving settlement onto `PolicyPaymentInstallment` rather than adding a second slot. + + **One long-standing open question is closed by this spec:** `DATGRAL.[NUM UTIL]` is authoritative for Utilities↔Seguros reconciliation and **`UTILSEG` must not be used** — its numbers resolve to unrelated people under every reading tested (name match 58/1,024 vs. 298/563 for `NUM UTIL`), and where the two sources overlap they contradict each other on 170 of 218 shared ids. This matters to step 11's customer-number recycling, which touches the same identity space. ## Status @@ -148,6 +159,8 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r **Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone. +**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end. + ## Decisions (locked) - **Stack:** Next.js + NestJS + Prisma + **MySQL** (locked earlier — see engine rationale above). @@ -164,9 +177,11 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r - **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing. - **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed. -## Open design questions (step 11 — need Jorge before/while building) +## Open design questions (steps 11 & 12 — need Jorge before/while building) -Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md`: +Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md` (step 11) and `docs/INSURANCE_FEATURES_SPEC.md` (step 12): + +**Step 11 — utilities/ops side:** - OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider). - Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill. @@ -174,6 +189,17 @@ Unlike the ops items above, these block design decisions, not just infrastructur - The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility. - Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention. +**Step 12 — insurance side:** + +- Which SES region + verified sending identity/configuration set the renewal mail goes out under, and whether it reuses the existing IAM credentials or gets its own scoped `ses:SendEmail` user. (Provider and budget are *not* open — SES is settled.) +- What to do with the 78 policyholders who have no email on file: skip silently, or produce a print worklist? Recommended: the worklist, since `aviso-renovacion` already renders exactly those letters. +- Whether renewal notices go out in Spanish or English — `Customer` carries no language preference. +- What "garantías" refers to — it has zero referent in the legacy data, and it blocks the liquidación batch's exclusion filter. +- Whether policy settlement should move onto `PolicyPaymentInstallment` (recommended) or gain a second slot on `Policy`, and whether to backfill the ≤41 MULT second settlements lost in migration. +- Whether batch liquidación warrants a new MANAGER-level `policy:liquidate` ability (recommended) or should reuse the existing STAFF-level `policy:update`. +- **What "Solicitud Atlas" actually is** — an application form or a certificate. These are different artifacts with different data and timing; this blocks the whole certificate feature. +- **Carrier integration direction** — outbound quote/issue (which ANA's SOAP service supports today) or inbound sync of the office's existing book (which nothing found suggests either carrier offers)? This decides whether the feature is buildable at all. Bundle with the other three carrier questions into one call to Grupo Valore ((55) 5480-4000): WSDL + credentials for the ANA service, whether a cartera/portfolio download exists for an agent's own book, whether GMX daños has any machine interface, and whether one credential spans both carriers. ("GDMX" is resolved — it was a typo for `GMX`.) + ## Verification - Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check. diff --git a/RESUME.md b/RESUME.md index 9b3cb4f..519cb7c 100644 --- a/RESUME.md +++ b/RESUME.md @@ -200,9 +200,12 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8. - **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences: - there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`. - Prisma CLI is run as `npx prisma@5`. -- **Dev servers** (both must be up to use the UI): - - API `cd apps/api && ./node_modules/.bin/nest start --watch` → `:3001` - - Web `cd apps/web && ./node_modules/.bin/next dev` → `:3000` +- **Dev servers** (both must be up to use the UI). ⚠️ **Ports come from the env files, not the + framework defaults** — `apps/api/.env` sets `PORT=4501` and `WEB_ORIGIN=http://localhost:4500`, + and `apps/web/.env.local` points at `NEXT_PUBLIC_API_ORIGIN=http://localhost:4501`. This doc + said `:3001`/`:3000` until 2026-07-27; that was wrong and cost a debugging detour. + - API `cd apps/api && ./node_modules/.bin/nest start --watch` → **`:4501`** + - Web `cd apps/web && ./node_modules/.bin/next dev -p 4500` → **`:4500`** - Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default). - **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`. diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts index 93219ee..98c1bff 100644 --- a/apps/api/src/billing/billing.controller.ts +++ b/apps/api/src/billing/billing.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Get, @@ -22,7 +23,11 @@ import { LedgerDirection, MovementSort, } from "./billing.service"; -import { CreateMovementDto } from "./movement.dto"; +import { + BatchCreateDto, + CreateMovementDto, + ResolveOutstandingDto, +} from "./movement.dto"; const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"]; const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"]; @@ -46,6 +51,11 @@ function one(allowed: T[], value: string | undefined): T | undefined { return allowed.includes(value as T) ? (value as T) : undefined; } +/** Tri-state query flag: "true"/"false" filter, anything else means no filter. */ +function flag(v: string | undefined): boolean | undefined { + return v === "true" ? true : v === "false" ? false : undefined; +} + /** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */ function parseDate(v: string | undefined, endOfDay = false): Date | undefined { if (!v) return undefined; @@ -97,6 +107,18 @@ export class BillingController { }); } + /** + * Every movement cut against one check, with its total — the reconciliation + * view replacing the legacy REPORTE CHEQUE COUNT. Declared before the + * `customers/:id` and `:id`-shaped routes so the literal path wins. + */ + @Get("by-check") + byCheck(@Query("checkNumber") checkNumber?: string) { + const n = checkNumber?.trim(); + if (!n) throw new BadRequestException("checkNumber es obligatorio"); + return this.billing.byCheck(n); + } + /** One customer's full statement across both business lines. */ @Get("customers/:id") statement(@Param("id") id: string) { @@ -115,6 +137,8 @@ export class BillingController { @Query("typeId") typeId?: string, @Query("source") source?: string, @Query("customerId") customerId?: string, + @Query("outstanding") outstanding?: string, + @Query("checkNumber") checkNumber?: string, @Query("from") from?: string, @Query("to") to?: string, @Query("sort") sort?: string, @@ -129,6 +153,8 @@ export class BillingController { typeId: typeId || undefined, source: source || undefined, customerId: customerId || undefined, + outstanding: flag(outstanding), + checkNumber: checkNumber?.trim() || undefined, from: parseDate(from), to: parseDate(to, true), sort: one(MOVEMENT_SORTS, sort) ?? "date_desc", @@ -150,6 +176,42 @@ export class BillingController { return tx; } + /** + * Batch capture: many customers' receipts against one physical check. + * Same ability as single capture — batching is still capturing. + */ + @Post("batch") + @RequireAbility("ledger:create") + async createBatch(@Body() dto: BatchCreateDto, @Req() req: Request) { + const result = await this.billing.createBatch(dto); + void this.audit.log(this.actingId(req), "ledger.batch", { + checkNumber: dto.checkNumber, + count: result.count, + total: result.total, + currency: result.currency, + }); + return result; + } + + /** + * Resolve an outstanding (NOPAGO) row — `ledger:create`, not `ledger:void`: + * resolving completes a capture, it doesn't reverse one. + */ + @Post(":id/resolve-outstanding") + @RequireAbility("ledger:create") + async resolveOutstanding( + @Param("id") id: string, + @Body() dto: ResolveOutstandingDto, + @Req() req: Request, + ) { + const tx = await this.billing.resolveOutstanding(id, dto); + void this.audit.log(this.actingId(req), "ledger.resolve-outstanding", { + transactionId: id, + checkNumber: dto.checkNumber, + }); + return tx; + } + @Post(":id/void") @RequireAbility("ledger:void") async void(@Param("id") id: string, @Req() req: Request) { diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 43868bf..7a54077 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -1,7 +1,15 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { Prisma, TransactionDomain } from "@jorgecuadros/database"; +import { + Prisma, + TransactionCaptureSource, + TransactionDomain, +} from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; -import { CreateMovementDto } from "./movement.dto"; +import { + BatchCreateDto, + CreateMovementDto, + ResolveOutstandingDto, +} from "./movement.dto"; /** * Shared billing / statements module — plan step 6. @@ -54,12 +62,29 @@ export interface MovementParams { typeId?: string; source?: string; customerId?: string; + /** Restrict to captured-but-unpaid rows (the legacy NOPAGO worklist). */ + outstanding?: boolean; + /** Groups a capture batch: every row cut against one physical check. */ + checkNumber?: string; /** Inclusive ISO date bounds on `transactionDate`. */ from?: Date; to?: Date; sort: MovementSort; } +/** + * Non-client-supplied options for a capture. Kept out of the DTO on purpose: + * these are set by the calling *module*, never by an HTTP body, so a client + * can't label its own rows as machine-captured or forge a capture ref. + * See `BillingService.createBatch` for the seam contract. + */ +export interface CaptureOptions { + /** Defaults to BATCH for the HTTP path; the OCR pipeline passes OCR. */ + source?: TransactionCaptureSource; + /** Per-line artifact ids, positionally parallel to `dto.lines`. */ + refs?: (string | undefined)[]; +} + export interface BalanceParams { query?: string; page: number; @@ -112,6 +137,21 @@ function dec(v: Prisma.Decimal | null | undefined): string { */ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null }; +/** + * Outstanding ("NOPAGO") rows are captured but unpaid — the office recorded the + * bill without funds to cover it. They are excluded from every *balance* + * aggregate, exactly as the legacy `SALDOS ULTIMO 0` query did with its + * `HAVING NOPAGO = 0`: the office hasn't paid the bill, so it isn't yet owed by + * the customer. Resolving one (POST /billing/:id/resolve-outstanding) clears the + * flag and the amount starts counting. + * + * This is deliberately narrower than NOT_VOIDED. Voided rows are excluded + * everywhere; outstanding rows are excluded only from balances — the movement + * browser still totals them, because "how much water did we capture in April" + * means every captured row regardless of whether the check cleared. + */ +const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false }; + /** * Source tables excluded from the customer-facing statement. * @@ -160,6 +200,10 @@ export class BillingService { if (p.typeId) and.push({ typeId: p.typeId }); if (p.source) and.push({ legacySourceTable: p.source }); if (p.customerId) and.push({ customerId: p.customerId }); + if (p.outstanding !== undefined) and.push({ outstanding: p.outstanding }); + // Exact match, not `contains`: this is the by-check reconciliation lookup, + // where "1234" must not drag in "51234". + if (p.checkNumber) and.push({ checkNumber: p.checkNumber }); if (p.from || p.to) { and.push({ transactionDate: { @@ -216,6 +260,7 @@ export class BillingService { message: true, legacySourceTable: true, voidedAt: true, + outstanding: true, type: { select: { nameEn: true, nameEs: true } }, customer: { select: { id: true, name: true, nameSource: true, city: true }, @@ -263,6 +308,7 @@ export class BillingService { source: r.legacySourceTable, type: r.type, voided: r.voidedAt != null, + outstanding: r.outstanding, customerId: r.customer.id, customerName: r.customer.name, customerNameSource: r.customer.nameSource, @@ -356,7 +402,7 @@ export class BillingService { MAX(t.transactionDate) AS lastMovement FROM customers c JOIN transactions t ON t.customerId = c.id - WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter} + WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter} GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state ${having} ${orderBy} @@ -368,7 +414,10 @@ export class BillingService { SELECT c.id FROM customers c JOIN transactions t ON t.customerId = c.id - WHERE 1 = 1 ${nameFilter} ${txFilter} + -- Must match the page query's filters exactly, or the total disagrees + -- with the rows. (The void exclusion was missing here before the + -- outstanding work; a voided-only customer inflated the count.) + WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter} GROUP BY c.id ${having} ) x @@ -601,7 +650,20 @@ export class BillingService { const rows = await this.prisma.transaction.findMany({ where: { customerId, - legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] }, + // NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and + // `NULL NOT IN (...)` is NULL, not true — so every app-captured row + // (which has no legacySourceTable) silently vanished from the + // statement while still showing in the movement browser. Rows the app + // books must appear on the customer's statement, so the null case is + // spelled out. + OR: [ + { legacySourceTable: null }, + { + legacySourceTable: { + notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], + }, + }, + ], }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { @@ -616,6 +678,7 @@ export class BillingService { message: true, legacySourceTable: true, voidedAt: true, + outstanding: true, type: { select: { nameEn: true, nameEs: true } }, }, }); @@ -624,9 +687,10 @@ export class BillingService { const movements = rows.map((r) => { const voided = r.voidedAt != null; const prev = running.get(r.currency) ?? new Prisma.Decimal(0); - // A voided row does not move the running balance — it shows struck-through - // with the balance unchanged from the previous live movement. - const next = voided ? prev : prev.plus(r.amount); + // Neither a voided row nor an outstanding (unpaid) one moves the running + // balance — both show tagged, with the balance unchanged from the previous + // live movement. Outstanding rows start counting once resolved. + const next = voided || r.outstanding ? prev : prev.plus(r.amount); running.set(r.currency, next); return { id: r.id, @@ -642,6 +706,7 @@ export class BillingService { source: r.legacySourceTable, type: r.type, voided, + outstanding: r.outstanding, /** Balance in this row's currency after applying it. */ balanceAfter: next.toFixed(2), }; @@ -675,7 +740,9 @@ export class BillingService { >(); for (const r of rows) { - if (r.voidedAt != null) continue; // voided rows never enter a total + // Voided rows never enter a total; outstanding rows don't either until + // they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`). + if (r.voidedAt != null || r.outstanding) continue; const c = perCurrency.get(r.currency) ?? { @@ -723,7 +790,7 @@ export class BillingService { { name: string; currency: string; total: Prisma.Decimal; count: number } >(); for (const r of rows) { - if (r.voidedAt != null) continue; + if (r.voidedAt != null || r.outstanding) continue; if (!r.amount.lessThan(0)) continue; const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar"; const key = `${name}|${r.currency}`; @@ -795,10 +862,220 @@ export class BillingService { reference: dto.reference, checkNumber: dto.checkNumber, message: dto.message, + outstanding: dto.outstanding ?? false, + captureSource: "MANUAL", }, }); } + /** + * Batch capture by check — many customers' receipts against one physical + * check. One `$transaction`, so a bad line rejects the whole batch rather + * than leaving a half-captured check that reconciles against nothing. + * + * Returns the check-level total alongside the rows so the UI can show it + * against the physical check amount, which is the entire point of the legacy + * flow this replaces (`CAPTURA *` feeding `EDITA CHEQUE COUNT`). + * + * ── Integration seam for OCR auto-capture (RECEIPT_CAPTURE_SPEC §2) ──────── + * This method is the SINGLE write path for multi-row capture, and the OCR + * pipeline is required to post through it rather than writing `Transaction` + * rows itself — one validation path, one audit trail. Three guarantees exist + * for that caller specifically, and must not be broken: + * + * 1. `items[i]` corresponds to `dto.lines[i]`. Prisma's array + * `$transaction` preserves order, so the caller can zip the result back + * onto its own records — which is how `StatementDocument.postedTransactionId` + * gets set after a confirmed batch posts. + * 2. `opts.refs[i]` stamps `captureRef` on row `i` (a `StatementDocument.id`). + * Re-posting a ref that already has a live row is rejected, so a + * double-clicked "confirm" or a retried job cannot double-charge a + * customer. Voided rows don't block a re-post — a corrected statement + * must be re-postable after its bad row is voided. + * 3. `opts.source` records the capture path; it is NOT accepted over HTTP, + * so a client cannot label its hand-keyed rows as machine-captured. + * + * Everything the OCR module adds on top (batches, per-document status, the + * review queue) lives in its own module; nothing about it needs to change + * this signature. + */ + async createBatch(dto: BatchCreateDto, opts: CaptureOptions = {}) { + const date = new Date(dto.transactionDate); + if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); + + // Validate every customer up front, in one query — a per-line lookup inside + // the transaction would be N round-trips and would fail halfway through. + const ids = [...new Set(dto.lines.map((l) => l.customerId))]; + const found = await this.prisma.customer.findMany({ + where: { id: { in: ids } }, + select: { id: true }, + }); + if (found.length !== ids.length) { + const known = new Set(found.map((c) => c.id)); + const missing = ids.filter((id) => !known.has(id)); + throw new BadRequestException( + `Cliente(s) no encontrado(s): ${missing.join(", ")}`, + ); + } + + // Duplicate-post guard (seam guarantee 2). Only live rows block: a voided + // row means the earlier post was reversed, so the corrected statement must + // be allowed through. + const refs = (opts.refs ?? []).filter((r): r is string => !!r); + if (refs.length) { + const clash = await this.prisma.transaction.findMany({ + where: { captureRef: { in: refs }, voidedAt: null }, + select: { captureRef: true }, + }); + if (clash.length) { + const dupes = [...new Set(clash.map((c) => c.captureRef))]; + throw new BadRequestException( + `Ya existen movimientos para: ${dupes.join(", ")}`, + ); + } + } + + const currency = dto.currency ?? "MXN"; + const source = opts.source ?? "BATCH"; + const created = await this.prisma.$transaction( + dto.lines.map((line, i) => + this.prisma.transaction.create({ + data: { + customerId: line.customerId, + domain: dto.domain, + amount: line.amount, + transactionDate: date, + currency, + typeId: dto.typeId, + checkNumber: dto.checkNumber, + period: line.period, + reference: line.reference, + message: line.message, + outstanding: line.outstanding ?? false, + captureSource: source, + captureRef: opts.refs?.[i], + }, + }), + ), + ); + + // Outstanding lines are captured but unfunded, so they don't belong in the + // figure staff reconcile against the physical check. + const total = created.reduce( + (sum, t) => (t.outstanding ? sum : sum.plus(t.amount)), + new Prisma.Decimal(0), + ); + + return { + /** Parallel to `dto.lines` — see seam guarantee 1. */ + items: created, + checkNumber: dto.checkNumber, + currency, + source, + count: created.length, + outstandingCount: created.filter((t) => t.outstanding).length, + total: total.toFixed(2), + }; + } + + /** + * Resolve an outstanding row: the check was finally cut. Takes the resolution + * date and check number and clears the flag, so the amount starts counting + * toward the balance. Legacy: "se actualiza registro con fecha del día y el + * cheque a pagar y quitas outstanding". + */ + async resolveOutstanding(id: string, dto: ResolveOutstandingDto) { + const tx = await this.prisma.transaction.findUnique({ + where: { id }, + select: { id: true, voidedAt: true, outstanding: true }, + }); + if (!tx) throw new NotFoundException(`Transaction ${id} not found`); + if (tx.voidedAt) { + throw new BadRequestException("El movimiento está anulado"); + } + if (!tx.outstanding) { + throw new BadRequestException("El movimiento no está pendiente de pago"); + } + const date = new Date(dto.resolvedDate); + if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); + + return this.prisma.transaction.update({ + where: { id }, + data: { + outstanding: false, + checkNumber: dto.checkNumber, + transactionDate: date, + }, + }); + } + + /** + * Every live movement cut against one check, plus its total — the + * reconciliation view replacing `EDITA CHEQUE ALF/COUNT/NUM` and + * `REPORTE POR CHEQUE`. Voided rows are dropped entirely (they reconcile + * against nothing); outstanding rows are listed but excluded from the total, + * since the check didn't fund them. + */ + async byCheck(checkNumber: string) { + const rows = await this.prisma.transaction.findMany({ + where: { checkNumber, voidedAt: null }, + orderBy: [{ transactionDate: "asc" }, { id: "asc" }], + select: { + id: true, + transactionDate: true, + domain: true, + amount: true, + currency: true, + reference: true, + period: true, + message: true, + outstanding: true, + type: { select: { nameEn: true, nameEs: true } }, + customer: { select: { id: true, name: true, nameSource: true } }, + }, + }); + + // Per currency: a check is one currency in practice, but the ledger has + // both and this module never sums across them. + const totals = new Map(); + for (const r of rows) { + if (r.outstanding) continue; + const e = + totals.get(r.currency) ?? + { currency: r.currency, total: new Prisma.Decimal(0), count: 0 }; + e.total = e.total.plus(r.amount); + e.count += 1; + totals.set(r.currency, e); + } + + return { + checkNumber, + items: rows.map((r) => ({ + id: r.id, + transactionDate: r.transactionDate, + domain: r.domain, + amount: r.amount, + currency: r.currency, + direction: r.amount.lessThan(0) ? "charge" : "credit", + reference: r.reference, + period: r.period, + message: r.message, + outstanding: r.outstanding, + type: r.type, + customerId: r.customer.id, + customerName: r.customer.name, + customerNameSource: r.customer.nameSource, + })), + count: rows.length, + outstandingCount: rows.filter((r) => r.outstanding).length, + totals: [...totals.values()].map((t) => ({ + currency: t.currency, + total: t.total.toFixed(2), + count: t.count, + })), + }; + } + /** Reverse a movement by marking it voided; it stops counting toward totals. */ async voidMovement(id: string, userId: string) { const tx = await this.prisma.transaction.findUnique({ diff --git a/apps/api/src/billing/movement.dto.ts b/apps/api/src/billing/movement.dto.ts index aadb104..fc3f857 100644 --- a/apps/api/src/billing/movement.dto.ts +++ b/apps/api/src/billing/movement.dto.ts @@ -1,10 +1,16 @@ import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, IsEnum, IsNumber, IsOptional, IsString, MinLength, + ValidateNested, } from "class-validator"; +import { Type } from "class-transformer"; import { Currency, TransactionDomain } from "@jorgecuadros/database"; /** @@ -24,4 +30,56 @@ export class CreateMovementDto { @IsOptional() @IsString() reference?: string; @IsOptional() @IsString() checkNumber?: string; @IsOptional() @IsString() message?: string; + /** + * Legacy "NOPAGO": the bill was captured but not actually paid (no funds). + * The row posts normally and stays visible, but is kept out of every balance + * aggregate until resolved — see BillingService's NOT_OUTSTANDING. + */ + @IsOptional() @IsBoolean() outstanding?: boolean; +} + +/** + * Resolving an outstanding row: the check finally got cut, so the movement + * takes the resolution date and check number and starts counting toward the + * balance. Legacy behavior: "se actualiza registro con fecha del día y el + * cheque a pagar y quitas outstanding". + */ +export class ResolveOutstandingDto { + @IsString() @MinLength(1) checkNumber!: string; + @IsString() @MinLength(1) resolvedDate!: string; +} + +/** One customer's line within a batch; check-level fields live on the parent. */ +export class BatchLineDto { + @IsString() @MinLength(1) customerId!: string; + @IsNumber() amount!: number; + + @IsOptional() @IsString() reference?: string; + @IsOptional() @IsString() period?: string; + @IsOptional() @IsString() message?: string; + @IsOptional() @IsBoolean() outstanding?: boolean; +} + +/** + * Batch capture by check — the legacy "Editor" flow: key many customers' + * receipts against one check, then reconcile the captured total against the + * physical check. Deliberately NOT a persisted batch entity: `checkNumber` is + * already a column, and grouping by it answers every legacy by-check query. + */ +export class BatchCreateDto { + @IsEnum(TransactionDomain) domain!: TransactionDomain; + @IsString() @MinLength(1) transactionDate!: string; + @IsString() @MinLength(1) checkNumber!: string; + + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() typeId?: string; + + // Capped so one request can't open a transaction over an unbounded row set; + // a physical check batch is tens of lines, not thousands. + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => BatchLineDto) + lines!: BatchLineDto[]; } diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 844e999..f7f4209 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -949,6 +949,111 @@ const edoCuentaDatos: ReportDef = { }, }; +/** + * REPORTE CHEQUE COUNT — everything captured against one check. + * + * The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC + * §1.3): staff key many customers' receipts against one physical check, then + * check that what was captured adds up to what the check was cut for. Replaces + * `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and + * `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized + * report. + * + * Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing + * its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are + * listed but excluded from the total, because the check never funded them. + */ +const chequeCount: ReportDef = { + slug: "cheque-count", + title: "Reporte por cheque", + description: + "Todos los movimientos capturados contra un mismo cheque, con el total " + + "para conciliar contra el importe físico del cheque. Los movimientos " + + "pendientes de pago (sin fondos) se listan pero no suman al total.", + domain: "estado-cuenta", + legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT", + format: "tabular", + params: [ + { + key: "checkNumber", + label: "Número de cheque", + kind: "text", + placeholder: "Ej. 10432", + }, + ], + columns: [ + { key: "customerName", label: "Cliente", type: "text" }, + { key: "reference", label: "Referencia", type: "text" }, + { key: "period", label: "Periodo", type: "text" }, + { key: "concept", label: "Concepto", type: "text" }, + { key: "transactionDate", label: "Fecha", type: "date" }, + { key: "status", label: "Estado", type: "text" }, + { key: "amount", label: "Importe", type: "money", align: "right" }, + ], + async run(prisma, p) { + const checkNumber = p.checkNumber?.trim(); + if (!checkNumber) { + return { + rows: [], + totals: { movimientos: 0 }, + subtitle: "Indique un número de cheque", + }; + } + + const rows = await prisma.transaction.findMany({ + where: { checkNumber, ...NOT_VOIDED }, + orderBy: [{ transactionDate: "asc" }, { id: "asc" }], + select: { + transactionDate: true, + amount: true, + currency: true, + reference: true, + period: true, + outstanding: true, + type: { select: { nameEn: true, nameEs: true } }, + customer: { select: { name: true, nameMissing: true } }, + }, + }); + + // Per currency, and never collapsed — same rule as the rest of the ledger. + const totals = new Map(); + let outstandingCount = 0; + for (const r of rows) { + if (r.outstanding) { + outstandingCount++; + continue; + } + totals.set( + r.currency, + (totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount), + ); + } + + const totalsOut: Record = { + movimientos: rows.length, + }; + for (const [currency, sum] of totals) { + totalsOut[`total ${currency}`] = sum.toFixed(2); + } + if (outstandingCount) totalsOut["sin fondos"] = outstandingCount; + + return { + rows: rows.map((r) => ({ + customerName: nameOf(r.customer), + reference: r.reference ?? "—", + period: r.period ?? "—", + concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar", + transactionDate: r.transactionDate.toISOString().slice(0, 10), + status: r.outstanding ? "Sin fondos" : "Pagado", + amount: r.amount.toFixed(2), + currency: r.currency, + })), + totals: totalsOut, + subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`, + }; + }, +}; + /* ------------------------------------------------------------------ export */ export const REPORTS: ReportDef[] = [ @@ -959,6 +1064,7 @@ export const REPORTS: ReportDef[] = [ vigente, avisoRenovacion, edoCuentaDatos, + chequeCount, ]; export function findReport(slug: string): ReportDef | undefined { diff --git a/apps/web/src/app/estado-cuenta/lote/page.tsx b/apps/web/src/app/estado-cuenta/lote/page.tsx new file mode 100644 index 0000000..f8d2292 --- /dev/null +++ b/apps/web/src/app/estado-cuenta/lote/page.tsx @@ -0,0 +1,557 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { CustomerPicker } from "@/components/CustomerPicker"; +import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api"; +import { useCan } from "@/lib/abilities"; +import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels"; +import type { + BatchCreateInput, + BillingFacets, + ByCheckResponse, + Currency, + LedgerCurrency, + TransactionDomain, +} from "@/lib/types"; + +/** + * Batch capture by check — the "Editor" screen from the legacy system + * (docs/RECEIPT_CAPTURE_SPEC.md §1.2). + * + * Staff key many customers' receipts against ONE physical check before cutting + * it, then check that the captured total matches the check's amount. That + * reconciliation is the whole point, so the running total is the most prominent + * thing on the page and an optional "importe del cheque" field turns it into a + * live difference. + * + * No batch entity is persisted: `checkNumber` is a plain column, and grouping + * by it answers every by-check question (see the "Reporte por cheque" report). + */ + +const DOMAINS: { key: TransactionDomain; label: string }[] = [ + { key: "UTILITY", label: "Servicios" }, + { key: "INSURANCE", label: "Seguros" }, + { key: "TRUST", label: "Fideicomiso" }, +]; + +interface Line { + /** Local row key — lines have no server identity until the batch posts. */ + key: number; + customerId: string; + customerName: string; + amount: string; + reference: string; + period: string; + outstanding: boolean; +} + +function blankLine(key: number): Line { + return { + key, + customerId: "", + customerName: "", + amount: "", + reference: "", + period: "", + outstanding: false, + }; +} + +export default function BatchCapturePage() { + return ( + + + + ); +} + +function BatchCapture() { + const canCapture = useCan("ledger:create"); + const [facets, setFacets] = useState(null); + + // Check-level fields — shared by every line. + const [domain, setDomain] = useState("UTILITY"); + const [currency, setCurrency] = useState("MXN"); + const [typeId, setTypeId] = useState(""); + const [checkNumber, setCheckNumber] = useState(""); + const [transactionDate, setTransactionDate] = useState( + new Date().toISOString().slice(0, 10), + ); + /** The physical check's amount, for reconciliation only — never submitted. */ + const [checkAmount, setCheckAmount] = useState(""); + + const [lines, setLines] = useState([blankLine(1), blankLine(2), blankLine(3)]); + const [nextKey, setNextKey] = useState(4); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [posted, setPosted] = useState(null); + + useEffect(() => { + getBillingFacets().then(setFacets).catch(() => setFacets(null)); + }, []); + + const filled = lines.filter( + (l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)), + ); + + // Charges are captured as positive numbers and signed on submit, matching + // MovementForm — staff type what's on the bill, not a negative. + const total = useMemo( + () => + filled + .filter((l) => !l.outstanding) + .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), + [filled], + ); + const outstandingTotal = useMemo( + () => + filled + .filter((l) => l.outstanding) + .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0), + [filled], + ); + + const checkAmt = Number(checkAmount); + const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt); + const diff = hasCheckAmt ? checkAmt - total : 0; + const reconciled = hasCheckAmt && Math.abs(diff) < 0.005; + + function update(key: number, patch: Partial) { + setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l))); + } + + function addLine() { + setLines((ls) => [...ls, blankLine(nextKey)]); + setNextKey((k) => k + 1); + } + + function removeLine(key: number) { + setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key))); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!checkNumber.trim()) { + setError("Indica el número de cheque."); + return; + } + if (filled.length === 0) { + setError("Captura al menos una línea con cliente y monto."); + return; + } + const dupes = filled + .map((l) => l.customerId) + .filter((id, i, arr) => arr.indexOf(id) !== i); + if (dupes.length) { + const names = filled + .filter((l) => dupes.includes(l.customerId)) + .map((l) => l.customerName); + if ( + !window.confirm( + `Hay más de una línea para el mismo cliente (${[...new Set(names)].join( + ", ", + )}). ¿Continuar?`, + ) + ) + return; + } + + const payload: BatchCreateInput = { + domain, + transactionDate, + checkNumber: checkNumber.trim(), + currency: currency as Currency, + typeId: typeId || undefined, + lines: filled.map((l) => ({ + customerId: l.customerId, + // Every line of a check batch is a charge the office paid out. + amount: -Math.abs(Number(l.amount)), + reference: l.reference.trim() || undefined, + period: l.period.trim() || undefined, + outstanding: l.outstanding || undefined, + })), + }; + + setSaving(true); + setError(null); + try { + await createMovementBatch(payload); + // Re-read through the by-check view so the confirmation shows what's + // actually stored (including anything captured against this check + // earlier), not just what this request sent. + setPosted(await getByCheck(payload.checkNumber)); + } catch (e2) { + setError((e2 as Error)?.message ?? "No se pudo guardar el lote."); + } finally { + setSaving(false); + } + } + + function reset() { + setPosted(null); + setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]); + setNextKey((k) => k + 3); + setCheckNumber(""); + setCheckAmount(""); + } + + if (!canCapture) { + return ( +
+ No tienes permiso para capturar movimientos. +
+ ); + } + + if (posted) { + return ( + <> +
+
+

Lote capturado

+

+ Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "} + {posted.count === 1 ? "movimiento" : "movimientos"} +

+
+
+ + + Volver a estado de cuenta + +
+
+ +
+ {posted.totals.map((t) => ( +
+ {t.currency} + + Total del cheque {formatMoney(t.total, t.currency)} + + {formatNumber(t.count)} movimientos +
+ ))} + {posted.outstandingCount > 0 && ( +
+ + {formatNumber(posted.outstandingCount)} sin fondos (no suman al + total) + +
+ )} +
+ +
+ + + + + + + + + + + + {posted.items.map((i) => ( + + + + + + + + ))} + +
ClienteReferenciaPeriodoEstadoMonto
+ + {i.customerName} + + {i.reference || "—"}{i.period || "—"}{i.outstanding ? "Sin fondos" : "Pagado"} + + {formatMoney(i.amount, i.currency)} + +
+
+ +

+ Para imprimir la conciliación, usa el reporte{" "} + + Reporte por cheque + + . +

+ + ); + } + + return ( + <> +
+
+

Captura por cheque

+

+ Captura los recibos de varios clientes contra un mismo cheque y + concilia el total antes de guardar. +

+
+ + Cancelar + +
+ + {error &&
{error}
} + +
+
+

+ Datos del cheque +

+
+ + + + + + +
+
+ +
+
+

+ Recibos ({formatNumber(filled.length)}) +

+ +
+ +
+ + + + + + + + + + + + {lines.map((l) => ( + + + + + + + + + ))} + +
Cliente *ReferenciaPeriodo + Monto * + Sin fondos +
+ + update(l.key, { customerId: id, customerName: name }) + } + /> + + + update(l.key, { reference: e.target.value }) + } + /> + + update(l.key, { period: e.target.value })} + placeholder="2026-07" + /> + + update(l.key, { amount: e.target.value })} + placeholder="0.00" + /> + + + update(l.key, { outstanding: e.target.checked }) + } + aria-label="Sin fondos" + /> + + +
+
+
+ +
+

+ Conciliación +

+
+
+ {currency} + + Capturado {formatMoney(String(-total), currency)} + + {formatNumber(filled.filter((l) => !l.outstanding).length)} recibos +
+ {outstandingTotal > 0 && ( +
+ + Sin fondos{" "} + {formatMoney(String(-outstandingTotal), currency)}{" "} + (no suma al cheque) + +
+ )} + {hasCheckAmt && ( +
+ + {reconciled ? ( + Cuadra con el cheque + ) : ( + <> + Diferencia{" "} + + {formatMoney(String(diff), currency)} + + + )} + +
+ )} +
+
+ +
+ + + Cancelar + +
+
+ + ); +} diff --git a/apps/web/src/app/estado-cuenta/page.tsx b/apps/web/src/app/estado-cuenta/page.tsx index d1b3259..577a9cf 100644 --- a/apps/web/src/app/estado-cuenta/page.tsx +++ b/apps/web/src/app/estado-cuenta/page.tsx @@ -10,6 +10,7 @@ import { getBillingStats, listBalances, listMovements, + resolveOutstanding, voidMovement, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; @@ -117,6 +118,8 @@ function BillingBrowser() { const [direction, setDirection] = useState(""); const [typeId, setTypeId] = useState(""); const [source, setSource] = useState(""); + // "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones. + const [outstanding, setOutstanding] = useState<"" | "true" | "false">(""); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [movementSort, setMovementSort] = useState("date_desc"); @@ -126,6 +129,7 @@ function BillingBrowser() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [captureOpen, setCaptureOpen] = useState(false); + const [resolving, setResolving] = useState(null); const debounceRef = useRef>(); @@ -165,6 +169,7 @@ function BillingBrowser() { direction: direction || undefined, typeId: typeId || undefined, source: source || undefined, + outstanding: outstanding === "" ? undefined : outstanding === "true", from: from || undefined, to: to || undefined, sort: movementSort, @@ -188,6 +193,7 @@ function BillingBrowser() { direction, typeId, source, + outstanding, from, to, movementSort, @@ -304,16 +310,35 @@ function BillingBrowser() { ))} {view === "movimientos" && canCapture && ( - +
+ + Captura por cheque + + +
)} + {view === "movimientos" && resolving && ( + setResolving(null)} + onDone={() => { + setResolving(null); + runSearch(movements?.page ?? 1); + getBillingStats() + .then(setStats) + .catch(() => setStats(null)); + }} + /> + )} + {view === "movimientos" && captureOpen && (
@@ -447,6 +472,21 @@ function BillingBrowser() { + +
+ + + ); +} + function Pager({ page, pageCount, diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ce2f1bd..32780bc 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -2102,6 +2102,14 @@ button { color: var(--ink); } +/* Captured-but-unpaid (legacy NOPAGO). Deliberately not the same treatment as + a voided row: the movement is real and still pending, it just doesn't count + toward the balance until a check resolves it. */ +.tx-outstanding { + color: var(--warn, #b45309); + font-weight: 600; +} + /* Charges broken out by concept, with a proportional bar. `.card` carries no padding, so the list pads itself — otherwise the total sits on the border. */ .concept-list { diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index 846bc4c..719dbab 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -20,6 +20,10 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] { href: "/servicios", label: "Propiedades" }, { href: "/polizas", label: "Pólizas" }, { href: "/estado-cuenta", label: "Estado de cuenta" }, + // Daily data-entry screen (the legacy "Editor"), so it earns a top-level + // entry rather than living one click inside the Movimientos tab. Hidden from + // VIEWER, who can't capture anyway — the page itself also refuses. + { href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" }, { href: "/banco", label: "Chequera" }, { href: "/reportes", label: "Reportes" }, { href: "/catalogos", label: "Catálogos", ability: "lookup:manage" }, @@ -27,12 +31,33 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] { href: "/operaciones", label: "Operaciones", ability: "db:manage" }, ]; +/** + * Which nav entry is highlighted for a path. Longest matching href wins, so a + * nested route (`/estado-cuenta/lote`) highlights its own entry instead of also + * lighting up its parent (`/estado-cuenta`) — while `/estado-cuenta/`, which + * has no entry of its own, still correctly highlights the parent. + */ +function activeHref(pathname: string | null): string | null { + if (!pathname) return null; + let best: string | null = null; + for (const item of NAV) { + const match = item.exact + ? pathname === item.href + : pathname === item.href || pathname.startsWith(`${item.href}/`); + if (match && (best === null || item.href.length > best.length)) { + best = item.href; + } + } + return best; +} + export function AppShell({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); const [user, setUser] = useState(null); const [checking, setChecking] = useState(true); const [loggingOut, setLoggingOut] = useState(false); + const current = activeHref(pathname); useEffect(() => { let alive = true; @@ -94,9 +119,7 @@ export function AppShell({ children }: { children: ReactNode }) {