feat(billing): shared statements module across both business lines

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>
This commit is contained in:
2026-07-22 23:29:19 -07:00
co-authored by Claude Opus 4.8
parent 9de8e4e6c0
commit 2c6a6bf60b
13 changed files with 2789 additions and 1 deletions
+225
View File
@@ -1820,3 +1820,228 @@ button {
white-space: nowrap;
border: 0;
}
/* ================================================================
Estado de cuenta (billing / statements) — plan step 6
================================================================ */
/* A balance is signed: negative means the customer owes the office, positive
means they hold a credit. `flat` is a real state (settled to zero), not a
fallback, so it gets its own muted treatment rather than inheriting either. */
.bal-amount {
font-family: var(--font-mono);
font-weight: 700;
font-feature-settings: "tnum" 1;
white-space: nowrap;
}
.bal-amount.owing {
color: var(--negative);
}
.bal-amount.credit {
color: var(--positive);
}
.bal-amount.flat {
color: var(--muted);
}
.bal-running {
font-family: var(--font-mono);
font-size: 12.5px;
font-feature-settings: "tnum" 1;
white-space: nowrap;
color: var(--ink-soft);
}
.bal-running.owing {
color: var(--negative);
}
.bal-running.credit {
color: var(--positive);
}
.bal-row .bal-side {
text-align: right;
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-end;
min-width: 160px;
}
.bal-side .bal-amount {
font-size: 16px;
}
.bal-phrase {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.bal-phrase.owing {
color: var(--negative);
}
.bal-phrase.credit {
color: var(--positive);
}
.bal-phrase.flat {
color: var(--muted);
}
/* The customer's other-currency balance, shown so a peso figure is never
mistaken for the whole picture. */
.bal-other {
font-size: 11.5px;
color: var(--muted);
font-family: var(--font-mono);
}
@media (max-width: 640px) {
.bal-row .bal-side {
align-items: flex-start;
text-align: left;
min-width: 0;
}
}
/* Currency cards on the statement double as the movement-table currency
switch, so they are buttons, not divs. */
.bal-card {
text-align: left;
cursor: pointer;
font: inherit;
border: 1px solid var(--line);
transition: border-color 0.15s ease, transform 0.15s ease;
}
.bal-card:hover {
transform: translateY(-1px);
}
.bal-card.selected {
border-color: var(--brand-600);
box-shadow: 0 0 0 1px var(--brand-600);
}
.bal-breakdown {
display: grid;
grid-template-columns: auto 1fr;
gap: 1px 8px;
margin-top: 8px;
font-size: 12px;
align-items: baseline;
}
.bal-breakdown-label {
color: var(--muted);
font-size: 11.5px;
}
/* Ledger-wide charge/credit totals in the page header. */
.ledger-chip {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
}
.ledger-chip-cur {
font-weight: 700;
font-size: 12px;
letter-spacing: 0.06em;
color: var(--muted);
}
.ledger-chip-figs {
display: flex;
flex-direction: column;
line-height: 1.3;
}
.ledger-chip-label {
font-size: 11px;
color: var(--muted);
}
/* Totals for the current movement filter — deliberately above the table, so a
filtered view can't be read as if it were the whole ledger. */
.filtered-totals {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
.filtered-total {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 14px;
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface-2);
font-size: 12.5px;
color: var(--muted);
}
.filtered-total-cur {
font-weight: 700;
letter-spacing: 0.06em;
color: var(--ink-soft);
}
.filtered-total-net strong {
font-family: var(--font-mono);
color: var(--ink);
}
/* Charges broken out by concept, with a proportional bar. */
.concept-list {
display: flex;
flex-direction: column;
}
.concept-row {
display: grid;
grid-template-columns: minmax(150px, 1.2fr) minmax(60px, 2fr) auto;
gap: 14px;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid var(--line);
}
.concept-row:last-child {
border-bottom: none;
}
.concept-name {
font-size: 13.5px;
font-weight: 600;
display: flex;
flex-direction: column;
}
.concept-count {
font-size: 11.5px;
font-weight: 500;
color: var(--muted);
}
.concept-bar {
height: 7px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
}
.concept-bar span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--negative);
opacity: 0.55;
}
.concept-total {
text-align: right;
font-size: 13px;
}
@media (max-width: 640px) {
.concept-row {
grid-template-columns: 1fr auto;
}
.concept-bar {
display: none;
}
}
/* Cross-link out of a detail hero (statement -> customer file). */
.hero-links {
margin-top: 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
position: relative;
z-index: 1;
}