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>