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 <noreply@anthropic.com>
- New reports backend (registry, service, controller, outputs, types)
with catalog endpoint + slug/CSV/XLSX/PDF/print outputs.
- /reportes catalog + /reportes/[slug] runner; ReportRunner + ContextReports
components wire pre-filtered links from domain pages.
- Fix: /reportes/[slug] now reads searchParams and forwards initialParams to
ReportRunner so /reportes/edo-cuenta-datos?customerId=... auto-runs
instead of dropping the id and forcing a manual customer search.
- /inicio landing page; root + login redirect to /inicio.
- Company header env vars + logo asset for PDF/print rendering.
- exceljs + pdfkit deps.
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 <noreply@anthropic.com>
TRASPASOS PAYPAL's 7-figure balances exposed three layout bugs on
/estado-cuenta/[id], all invisible on normal small-figure customers:
- summary/línea cards: `summary-grid` uses auto-fill, so a single- or
two-currency card never widens past the min track (~190px) however wide
the page is. The 24px nowrap headline (-$7,028,533.44) overflowed the
card border. Widen the min track to 260px so the figure fits in full,
and drop the size to 22px. Explicitly no ellipsis — a truncated money
figure reads as a wrong number.
- `concept-list` had no horizontal padding (`.card` carries none), so the
concept totals sat flush on the card border. Pad it.
- the Cargo/Abono direction label was an inline span glued to the amount;
make it a block so it drops onto its own line. Same fix applied to the
movement-list page for consistency.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Plan step 6 — the payoff of the unified customer record: a utility charge
and an insurance payment finally sit on the same page, under the same
person, with a running balance.
API (apps/api/src/billing/):
- GET /billing — cross-customer movement browser. Search over customer,
referencia, cheque, concepto and periodo; filters for business line,
currency, charge-vs-credit, concept, origin table and a from/to date
range; 5 sorts. Returns totals for the whole filtered set, not just the
page, so a filtered view can't be misread as the full ledger.
- GET /billing/balances — per-customer receivables worklist with
owing/credit/settled buckets and 4 sorts. Raw SQL (parameterized via
Prisma.sql): needs conditional sums per currency and per direction in
one pass plus ordering and pagination on a computed balance, none of
which groupBy expresses.
- GET /billing/stats, /billing/facets, /billing/customers/:id.
Web:
- /estado-cuenta — two views over the same ledger, because staff ask two
different questions: "Saldos por cliente" (who owes what) and
"Movimientos" (every charge and credit).
- /estado-cuenta/[id] — the statement: balance per currency, the same
balance split by business line, charges broken out by concept, and the
full movement list with a running balance.
- Cross-linked from the customer and property detail pages.
Two data findings shape the whole module:
1. transactions.amount is a signed ledger. Every charge type is negative
without exception (WATER 3115/3117, ELECTRIC 2191/2191, PROPERTY TAXES
926/926, TRUST FEE 188/188) and every deposit type positive (CHECK and
CASH DEPOSIT, PAYPAL, all of EFECTIVO). So SUM(amount) is the balance
and negative means the customer owes the office.
2. Currency is not summable. 912 of the 1269 customers with a ledger move
in both MXN and USD, the charge side is MXN-only while receipts arrive
in both, and no per-movement exchange rate was ever stored. A single
"total balance" would be a figure that never existed in the books, so
every total is reported per currency and the balance filter/sort takes
a currency argument rather than collapsing.
Also: type_transactions.nameEs is entirely null (the legacy TYPE OF TRX
ESPAÑOL column is empty in all 79 rows), so Spanish concept names come
from a label map in labels.ts; the entries that are payee names rather
than categories fall through untranslated, which is correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>