feat(statements): scope the estado de cuenta to the current year, oldest-first
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s

The office's EDO CUENTA sheet has always been a *year* statement: a balance
forward line dated January 1st, then that year's movements in the order they
happened. Both of ours read the other way — every year the customer ever had,
newest first — so staff comparing the screen against the printed sheet were
reading two different documents.

Movements are now bounded to the calendar year and returned ascending, on the
screen (/estado-cuenta/[id]) and in the printable `edo-cuenta-datos` report
alike.

Earlier rows are dropped from the *list*, not from the arithmetic. The balance
floor normally lands on January 1st already, so for most customers nothing
extra is dropped at all; when it doesn't — a customer the last legacy publish
skipped, or one that never had an opening balance — the earlier rows are
folded into a carried balance and shown as a single "saldo anterior" line.
Discarding them instead would restart every balance at zero on January 1st and
nothing would throw; the numbers would just be wrong, which is how the
double-counting bug survived for years. `opening` is exposed per currency and
per business line so the totals still reconcile against the last running
balance printed.

Two things the report was missing on its own are fixed while it is being
touched, since it must agree with the screen to the peso:

  - it never applied the balance floor, so every pre-cutover row was counted
    twice — once inside the opening balance and once as itself;
  - its source-table exclusion used a bare `notIn`, and `NULL NOT IN (...)` is
    NULL rather than true, so every app-captured row (which has no
    legacySourceTable) silently vanished from the printout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:45:15 -07:00
co-authored by Claude Opus 5
parent 75e9f582b4
commit bc749055e7
5 changed files with 398 additions and 53 deletions
+87 -14
View File
@@ -701,11 +701,14 @@ export class BillingService {
/**
* One customer's statement across both business lines.
*
* Returns the *whole* ledger rather than a page of it: the heaviest customer
* Scoped to the current calendar year and listed oldest-first, matching the
* legacy EDO CUENTA report the office has printed for years: an opening
* balance at the top, then the year's movements in the order they happened.
*
* Returns the *whole* year rather than a page of it: the heaviest customer
* carries 365 movements (mean 26), and a running balance is meaningless if
* the client only holds a slice. The running balance is accumulated per
* currency in chronological order, then the list is handed back newest-first
* with each row's balance-after already attached.
* currency in chronological order, with each row's balance-after attached.
*/
async statement(customerId: string) {
const customer = await this.prisma.customer.findUnique({
@@ -790,15 +793,52 @@ export class BillingService {
},
});
// The statement covers one calendar year. The floor above normally lands on
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
// per customer per year — in which case nothing extra is dropped here. When
// it doesn't (a customer the last publish skipped, or one that never had an
// opening balance), the earlier rows still have to be *counted* or every
// balance below is wrong, so they are folded into `opening` rather than
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
/** Balance carried into `yearStart`, per currency. */
const opening = new Map<string, Prisma.Decimal>();
/** The same carried balance split by business line, keyed `domain|currency`. */
const openingByDomain = new Map<
string,
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
>();
/** The rows the statement lists — this year's. Totals are built from these. */
const visible: typeof rows = [];
const movements = rows.flatMap((r) => {
const voided = r.voidedAt != null;
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
// 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);
const counted = !voided && !r.outstanding;
const next = counted ? prev.plus(r.amount) : prev;
running.set(r.currency, next);
if (r.transactionDate < yearStart) {
if (counted) {
opening.set(r.currency, next);
const dk = `${r.domain}|${r.currency}`;
const od = openingByDomain.get(dk) ?? {
domain: r.domain,
currency: r.currency,
amount: new Prisma.Decimal(0),
};
od.amount = od.amount.plus(r.amount);
openingByDomain.set(dk, od);
}
return [];
}
visible.push(r);
return {
id: r.id,
transactionDate: r.transactionDate,
@@ -818,7 +858,6 @@ export class BillingService {
balanceAfter: next.toFixed(2),
};
});
movements.reverse();
// Per-currency summary, and the same split by business line so the two
// ledgers are visibly one statement without being illegally added up.
@@ -846,7 +885,29 @@ export class BillingService {
}
>();
for (const r of rows) {
for (const [currency] of opening) {
perCurrency.set(currency, {
currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
chargeCount: 0,
creditCount: 0,
count: 0,
first: null,
last: null,
});
}
for (const [key, o] of openingByDomain) {
perDomain.set(key, {
domain: o.domain,
currency: o.currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
count: 0,
});
}
for (const r of visible) {
// 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;
@@ -896,7 +957,7 @@ export class BillingService {
string,
{ name: string; currency: string; total: Prisma.Decimal; count: number }
>();
for (const r of rows) {
for (const r of visible) {
if (r.voidedAt != null || r.outstanding) continue;
if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
@@ -915,25 +976,37 @@ export class BillingService {
propertyCount: customer._count.properties,
policyCount: customer._count.policies,
},
summary: [...perCurrency.values()].map((c) => ({
year: yearStart.getUTCFullYear(),
summary: [...perCurrency.values()].map((c) => {
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
return {
currency: c.currency,
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
opening: open.toFixed(2),
charges: c.charges.toFixed(2),
credits: c.credits.toFixed(2),
balance: c.charges.plus(c.credits).toFixed(2),
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
chargeCount: c.chargeCount,
creditCount: c.creditCount,
count: c.count,
firstMovement: c.first,
lastMovement: c.last,
})),
byDomain: [...perDomain.values()].map((d) => ({
};
}),
byDomain: [...perDomain.values()].map((d) => {
const open =
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
new Prisma.Decimal(0);
return {
domain: d.domain,
currency: d.currency,
opening: open.toFixed(2),
charges: d.charges.toFixed(2),
credits: d.credits.toFixed(2),
balance: d.charges.plus(d.credits).toFixed(2),
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
count: d.count,
})),
};
}),
byType: [...byType.values()]
.map((t) => ({
name: t.name,
+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from "@jorgecuadros/database";
import { BillingService } from "./billing.service";
/**
* The statement is a *year* statement, like the EDO CUENTA report the office
* prints: this year's movements, oldest-first, opening on the balance carried
* in from before it.
*
* The carrying is the part worth testing. Dropping earlier rows from the list
* is easy; dropping them from the arithmetic too would restart every balance at
* zero on January 1st, and nothing would throw — the numbers would just be
* wrong, which is exactly how the double-counting bug lived for years.
*/
describe("statement year scoping", () => {
const YEAR = new Date().getUTCFullYear();
function d(iso: string) {
return new Date(`${iso}T00:00:00.000Z`);
}
type RowSpec = {
id: string;
date: Date;
amount: string;
currency?: string;
domain?: string;
voidedAt?: Date | null;
outstanding?: boolean;
};
function row(r: RowSpec) {
return {
id: r.id,
transactionDate: r.date,
domain: r.domain ?? "UTILITY",
amount: new Prisma.Decimal(r.amount),
currency: r.currency ?? "MXN",
reference: null,
period: null,
checkNumber: null,
message: null,
legacySourceTable: null,
voidedAt: r.voidedAt ?? null,
outstanding: r.outstanding ?? false,
type: { nameEn: "WATER", nameEs: "AGUA" },
};
}
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
function serviceWith(rows: RowSpec[]) {
const prisma = {
customer: {
findUnique: jest.fn().mockResolvedValue({
id: "c1",
name: "CUADROS, JORGE H.",
preferredCurrency: "MXN",
_count: { properties: 0, policies: 0 },
}),
},
transaction: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue(rows.map(row)),
},
};
return new BillingService(prisma as never);
}
it("lists the year's movements oldest-first", async () => {
const s = await serviceWith([
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
]).statement("c1");
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
});
it("leaves earlier years off the list", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
]).statement("c1");
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
});
it("carries the earlier years' balance instead of discarding it", async () => {
// 1,000 credit left over from last year, 300 charged this year: the
// customer is 700 in credit, not 300 in debt.
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const mxn = s.summary.find((x) => x.currency === "MXN");
expect(mxn?.opening).toBe("1000.00");
expect(mxn?.charges).toBe("-300.00");
expect(mxn?.balance).toBe("700.00");
// The running balance on the listed row picks up where last year left off.
expect(s.movements[0].balanceAfter).toBe("700.00");
});
it("carries it per business line as well", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
]).statement("c1");
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
expect(line?.opening).toBe("1000.00");
expect(line?.balance).toBe("700.00");
});
it("still reports a currency that only moved in earlier years", async () => {
// Otherwise a customer sitting on a dollar credit they haven't touched all
// year would appear to have no dollar balance at all.
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const usd = s.summary.find((x) => x.currency === "USD");
expect(usd?.balance).toBe("180.83");
expect(usd?.count).toBe(0);
});
it("does not carry a voided earlier row", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const mxn = s.summary.find((x) => x.currency === "MXN");
expect(mxn?.opening).toBe("0.00");
expect(mxn?.balance).toBe("-300.00");
});
it("reports the year it covers", async () => {
const s = await serviceWith([
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
]).statement("c1");
expect(s.year).toBe(YEAR);
});
});
+73 -11
View File
@@ -15,6 +15,7 @@
*/
import { Prisma } from "@jorgecuadros/database";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
import {
intParam,
NOT_VOIDED,
@@ -751,8 +752,8 @@ const edoCuentaDatos: ReportDef = {
title: "Estado de cuenta",
description:
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
"ramo y concepto, y el historial completo de movimientos con saldo " +
"corrido. El reporte del cliente final.",
"ramo y concepto, y los movimientos del año en curso con saldo " +
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
domain: "estado-cuenta",
legacyName: "EDO CUENTA DATOS",
format: "statement",
@@ -789,13 +790,31 @@ const edoCuentaDatos: ReportDef = {
});
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
// as BillingService.statement so the numbers match what the customer
// already sees in /estado-cuenta/[id].
// The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and
// /estado-cuenta/[id] are the same statement — one printable, one on
// screen — and a customer holding both must not read two balances.
const floor = await prisma.transaction.findFirst({
where: {
customerId,
voidedAt: null,
type: { nameEn: BALANCE_FORWARD_TYPE },
},
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
});
const rows = await prisma.transaction.findMany({
where: {
customerId,
voidedAt: null,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
// drops every app-captured row (they have no legacySourceTable) — the
// same defect this report's on-screen twin was fixed for.
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [
"EFECTIVO",
@@ -806,6 +825,8 @@ const edoCuentaDatos: ReportDef = {
],
},
},
],
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: {
id: true,
@@ -822,12 +843,28 @@ const edoCuentaDatos: ReportDef = {
},
});
// Compute running balance per currency, then return newest-first.
// Scoped to the calendar year and listed oldest-first, the way the legacy
// EDO CUENTA sheet reads. Rows from earlier years still move the running
// balance — they are folded into `opening` and printed as a single "saldo
// anterior" line, which is what a BALANCE FORWARD row is.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const year = yearStart.getUTCFullYear();
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const opening = new Map<string, Prisma.Decimal>();
const visible: typeof rows = [];
const movements = rows.flatMap((r) => {
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
running.set(r.currency, next);
if (r.transactionDate < yearStart) {
opening.set(r.currency, next);
return [];
}
visible.push(r);
return {
date: r.transactionDate.toISOString().slice(0, 10),
domain: r.domain,
@@ -840,14 +877,38 @@ const edoCuentaDatos: ReportDef = {
balanceAfter: next.toFixed(2),
};
});
movements.reverse();
// Per-currency summary + per-domain breakdown.
// The carried balance, printed as the statement's first line — same shape
// as a movement row so it needs nothing special from the renderer.
const carried = [...opening.entries()]
.filter(([, amount]) => !amount.isZero())
.map(([currency, amount]) => ({
date: yearStart.toISOString().slice(0, 10),
domain: "UTILITY",
currency,
reference: "",
period: `Al cierre de ${year - 1}`,
checkNumber: "",
concept: "SALDO ANTERIOR",
amount: amount.toFixed(2),
balanceAfter: amount.toFixed(2),
}));
// Per-currency summary, seeded with the carried balance so it reconciles
// against the last running balance printed below.
const perCurrency = new Map<
string,
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
>();
for (const r of rows) {
for (const [currency, amount] of opening) {
perCurrency.set(currency, {
currency,
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
count: 0,
});
}
for (const r of visible) {
const c =
perCurrency.get(r.currency) ??
{
@@ -881,9 +942,10 @@ const edoCuentaDatos: ReportDef = {
count: c.count,
})),
{ __kind: "movements-header" },
...carried,
...movements,
],
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
};
},
};
+66 -6
View File
@@ -36,10 +36,12 @@ import type {
* charge and an insurance payment finally sit on the same page, under the same
* person, with a running balance.
*
* The running balance is per currency (the API accumulates it chronologically
* before handing the list back newest-first), so the movement table is scoped
* to one currency at a time — a column that alternated between pesos and
* dollars would be a meaningless number.
* The running balance is per currency, so the movement table is scoped to one
* currency at a time — a column that alternated between pesos and dollars would
* be a meaningless number.
*
* Like the legacy EDO CUENTA report, the table covers the current year only and
* runs oldest-first, opening on the balance carried in from before it.
*/
export default function EstadoCuentaDetailPage({
params,
@@ -194,7 +196,7 @@ function StatementView({ id }: { id: string }) {
<section className="section">
<SectionHead
rule="cuenta"
title="Movimientos"
title={`Movimientos ${data.year}`}
count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
right={
@@ -260,7 +262,7 @@ function StatementView({ id }: { id: string }) {
<div className="card">
{movements.length === 0 ? (
<div className="empty-inline">
Sin movimientos en {currency}
Sin movimientos de {data.year} en {currency}
{domain ? ` para ${domainLabel(domain)}` : ""}.
</div>
) : (
@@ -282,6 +284,26 @@ function StatementView({ id }: { id: string }) {
</tr>
</thead>
<tbody>
{/*
The carried balance, shown the way the legacy report shows
it: a BALANCE FORWARD line above the year's movements. It
only appears when there is something to carry — when the
customer's opening-balance row is itself dated inside this
year (the usual case) it is listed as an ordinary movement
and this row is zero, so it is left out.
Suppressed under a business-line filter: the carried balance
is the customer's, across both lines, and printing it above
one line's rows would read as that line's opening balance.
*/}
{!domain && Number(active?.opening ?? 0) !== 0 && (
<OpeningRow
opening={active!.opening}
currency={currency}
year={data.year}
canVoid={canVoid}
/>
)}
{movements.map((m) => (
<StatementRow
key={m.id}
@@ -481,6 +503,44 @@ function ConceptosSection({
);
}
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
function OpeningRow({
opening,
currency,
year,
canVoid,
}: {
opening: string;
currency: LedgerCurrency;
year: number;
canVoid: boolean;
}) {
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(`${year}-01-01T00:00:00.000Z`)}
</td>
<td className="tx-domain-cell">Ambas líneas</td>
<td>
Saldo anterior
<div className="tx-concept">Al cierre de {year - 1}</div>
</td>
<td className="tx-ref"></td>
<td className="num">
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
{formatMoney(opening, currency)}
</span>
</td>
<td className="num">
<span className={`bal-running ${balanceTone(opening)}`}>
{formatMoney(opening, currency)}
</span>
</td>
{canVoid && <td />}
</tr>
);
}
function StatementRow({
m,
canVoid,
+5
View File
@@ -1041,6 +1041,8 @@ export interface BillingFacets {
export interface StatementSummary {
currency: LedgerCurrency;
/** Balance carried in from before the statement year — legacy's BALANCE FORWARD. */
opening: string;
charges: string;
credits: string;
balance: string;
@@ -1054,6 +1056,7 @@ export interface StatementSummary {
export interface StatementDomainRow {
domain: TransactionDomain;
currency: LedgerCurrency;
opening: string;
charges: string;
credits: string;
balance: string;
@@ -1089,6 +1092,8 @@ export interface Statement {
propertyCount: number;
policyCount: number;
};
/** Calendar year the statement covers; movements are scoped to it. */
year: number;
summary: StatementSummary[];
byDomain: StatementDomainRow[];
byType: StatementTypeRow[];