diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index 607309f..be25aa9 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -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(); - const movements = rows.map((r) => { + /** Balance carried into `yearStart`, per currency. */ + const opening = new Map(); + /** 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) => ({ - currency: c.currency, - charges: c.charges.toFixed(2), - credits: c.credits.toFixed(2), - balance: 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) => ({ - domain: d.domain, - currency: d.currency, - charges: d.charges.toFixed(2), - credits: d.credits.toFixed(2), - balance: d.charges.plus(d.credits).toFixed(2), - count: d.count, - })), + 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: 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) => { + 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: open.plus(d.charges).plus(d.credits).toFixed(2), + count: d.count, + }; + }), byType: [...byType.values()] .map((t) => ({ name: t.name, diff --git a/apps/api/src/billing/statement-year.spec.ts b/apps/api/src/billing/statement-year.spec.ts new file mode 100644 index 0000000..64e8479 --- /dev/null +++ b/apps/api/src/billing/statement-year.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 3615bd6..523d245 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -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,22 +790,42 @@ 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, - legacySourceTable: { - notIn: [ - "EFECTIVO", - "EFECTIVO_BACKUP", - "EFECTIVO FM3", - "CHEQUE FM3", - "IVA 2015", - ], - }, + ...(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", + "EFECTIVO_BACKUP", + "EFECTIVO FM3", + "CHEQUE FM3", + "IVA 2015", + ], + }, + }, + ], }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { @@ -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(); - const movements = rows.map((r) => { + const opening = new Map(); + 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`, }; }, }; diff --git a/apps/web/src/app/estado-cuenta/[id]/page.tsx b/apps/web/src/app/estado-cuenta/[id]/page.tsx index 8455c50..a66e766 100644 --- a/apps/web/src/app/estado-cuenta/[id]/page.tsx +++ b/apps/web/src/app/estado-cuenta/[id]/page.tsx @@ -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 }) {
{movements.length === 0 ? (
- Sin movimientos en {currency} + Sin movimientos de {data.year} en {currency} {domain ? ` para ${domainLabel(domain)}` : ""}.
) : ( @@ -282,6 +284,26 @@ function StatementView({ id }: { id: string }) { + {/* + 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 && ( + + )} {movements.map((m) => ( + + {formatDate(`${year}-01-01T00:00:00.000Z`)} + + Ambas líneas + + Saldo anterior +
Al cierre de {year - 1}
+ + — + + + {formatMoney(opening, currency)} + + + + + {formatMoney(opening, currency)} + + + {canVoid && } + + ); +} + function StatementRow({ m, canVoid, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 46a2bc5..c694702 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -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[];