diff --git a/apps/api/src/billing/balance-floor.spec.ts b/apps/api/src/billing/balance-floor.spec.ts index 62e4687..447a2e1 100644 --- a/apps/api/src/billing/balance-floor.spec.ts +++ b/apps/api/src/billing/balance-floor.spec.ts @@ -115,13 +115,26 @@ describe("balance floor", () => { ); }); + /** + * statement() issues two findMany calls: first the period discovery (which + * years this customer has an archive for), then the statement rows. Select + * the rows query by its shape so adding another lookup later moves nothing + * here — the previous version indexed call 0 and broke the moment period + * support landed. + */ + function rowsQuery(findMany: jest.Mock) { + const call = findMany.mock.calls.find((c) => c[0]?.orderBy); + if (!call) throw new Error("statement() issued no ordered rows query"); + return call[0]; + } + it("bounds the statement at the floor, inclusive", async () => { const floor = new Date("2026-01-01T00:00:00Z"); const { service, findMany } = serviceWith(floor); await service.statement("c1"); - expect(findMany.mock.calls[0][0].where).toMatchObject({ + expect(rowsQuery(findMany).where).toMatchObject({ customerId: "c1", transactionDate: { gte: floor }, }); @@ -132,9 +145,7 @@ describe("balance floor", () => { await service.statement("c1"); - expect(findMany.mock.calls[0][0].where).not.toHaveProperty( - "transactionDate", - ); + expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate"); }); it("keeps the source-table exclusion alongside the floor", async () => { @@ -145,7 +156,7 @@ describe("balance floor", () => { await service.statement("c1"); - const where = findMany.mock.calls[0][0].where; + const where = rowsQuery(findMany).where; expect(where.OR).toEqual([ { legacySourceTable: null }, { legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } }, diff --git a/apps/api/src/billing/billing.controller.ts b/apps/api/src/billing/billing.controller.ts index 98c1bff..3d6307f 100644 --- a/apps/api/src/billing/billing.controller.ts +++ b/apps/api/src/billing/billing.controller.ts @@ -119,10 +119,23 @@ export class BillingController { return this.billing.byCheck(n); } - /** One customer's full statement across both business lines. */ + /** + * One customer's statement across both business lines, for one period. + * + * `year` omitted means the current one. Any earlier year is served from its + * imported archive; the response carries `availableYears` so the caller can + * offer only the periods this customer actually has. + */ @Get("customers/:id") - statement(@Param("id") id: string) { - return this.billing.statement(id); + statement(@Param("id") id: string, @Query("year") year?: string) { + let parsed: number | undefined; + if (year !== undefined && year !== "") { + parsed = Number(year); + if (!Number.isInteger(parsed)) { + throw new BadRequestException("year debe ser un año de cuatro dígitos"); + } + } + return this.billing.statement(id, parsed); } /** Cross-customer movement browser. */ diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index be25aa9..77a0f6a 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -221,6 +221,20 @@ export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transact * browser keep them — they're real money, just tracked separately * (FM3 = visa fee stream, EFECTIVO = cash receipt stream). */ +/** + * `legacySourceTable` of an imported prior period. + * + * A closed year arrives as its own Access snapshot and is tagged rather than + * dated (see migration/transform_transactions.py). The tag is what a period + * view filters on: the archives are not cleanly date-bounded — 2025 carries + * rows dated into 2026 — and legacy did not filter by date either, it selected + * `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly. + */ +export const periodSourceTable = (year: number) => `datos2@${year}`; + +/** Matches any imported period tag, for discovering which years a customer has. */ +const PERIOD_TABLE_PREFIX = "datos2@"; + const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ "EFECTIVO", "EFECTIVO_BACKUP", @@ -701,16 +715,22 @@ export class BillingService { /** * One customer's statement across both business lines. * - * 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. + * Scoped to one 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. + * + * `year` selects the period. The current year is read from the live tables; + * any earlier year is read from its imported archive, which legacy kept as a + * separate table and this reads by its `datos2@YYYY` tag. `availableYears` + * reports which periods this customer actually has, so a caller never offers + * a year that would render empty. * * 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, with each row's balance-after attached. */ - async statement(customerId: string) { + async statement(customerId: string, year?: number) { const customer = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { @@ -734,6 +754,36 @@ export class BillingService { throw new NotFoundException(`Customer ${customerId} not found`); } + // Which periods this customer has. The current year is always offered — + // it is the live ledger even when empty — and each imported archive adds + // the year it holds. + const archives = await this.prisma.transaction.findMany({ + where: { + customerId, + voidedAt: null, + legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX }, + }, + distinct: ["legacySourceTable"], + select: { legacySourceTable: true }, + }); + const thisYear = new Date().getUTCFullYear(); + const archiveYears = archives + .map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length))) + .filter((y) => Number.isInteger(y) && y < thisYear); + const availableYears = [...new Set([thisYear, ...archiveYears])].sort( + (a, b) => b - a, + ); + + // An unknown year would silently render as the current one, which reads as + // "this customer had no activity in 2019" rather than "there is no 2019". + const requested = year ?? thisYear; + if (!availableYears.includes(requested)) { + throw new NotFoundException( + `El cliente no tiene movimientos del periodo ${requested}.`, + ); + } + const isArchive = requested !== thisYear; + // One customer, so the balance floor is a single date rather than the // derived table the aggregate queries join. See NOT_SUPERSEDED: rows before // the opening balance are already inside it, and showing them would both @@ -759,21 +809,31 @@ export class BillingService { const rows = await this.prisma.transaction.findMany({ where: { customerId, - ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), - // NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and - // `NULL NOT IN (...)` is NULL, not true — so every app-captured row - // (which has no legacySourceTable) silently vanished from the - // statement while still showing in the movement browser. Rows the app - // books must appear on the customer's statement, so the null case is - // spelled out. - OR: [ - { legacySourceTable: null }, - { - legacySourceTable: { - notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], - }, - }, - ], + ...(isArchive + ? // An archive is already exactly one period's ledger, so the tag is + // the whole filter. The balance floor is deliberately NOT applied: + // it exists to stop a later opening balance double-counting the + // history it summarizes, and here that history is the thing being + // asked for. The exclusion list is moot too — an archive holds only + // DATOS2 rows, which is what legacy's year table held. + { legacySourceTable: periodSourceTable(requested) } + : { + ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), + // NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and + // `NULL NOT IN (...)` is NULL, not true — so every app-captured row + // (which has no legacySourceTable) silently vanished from the + // statement while still showing in the movement browser. Rows the app + // books must appear on the customer's statement, so the null case is + // spelled out. + OR: [ + { legacySourceTable: null }, + { + legacySourceTable: { + notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], + }, + }, + ], + }), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], select: { @@ -800,7 +860,18 @@ export class BillingService { // 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)); + // + // Deliberately open-ended at the top. A period is a table in legacy, not a + // date range, so whatever the office filed in it belongs to it — including + // the future-dated rows the current ledger carries (it runs to 2028). An + // upper bound would hide them from every view, which is not what legacy did + // and not what the office has been reading. + // + // An archive needs no fold at all: it *is* the period, and its own Jan-1 + // BALANCE FORWARD row is the carry, listed exactly as legacy listed it. + const yearStart = isArchive + ? new Date(0) + : new Date(Date.UTC(requested, 0, 1)); const running = new Map(); /** Balance carried into `yearStart`, per currency. */ @@ -976,7 +1047,8 @@ export class BillingService { propertyCount: customer._count.properties, policyCount: customer._count.policies, }, - year: yearStart.getUTCFullYear(), + year: requested, + availableYears, summary: [...perCurrency.values()].map((c) => { const open = opening.get(c.currency) ?? new Prisma.Decimal(0); return { diff --git a/apps/api/src/billing/statement-period.spec.ts b/apps/api/src/billing/statement-period.spec.ts new file mode 100644 index 0000000..35fd0e9 --- /dev/null +++ b/apps/api/src/billing/statement-period.spec.ts @@ -0,0 +1,36 @@ +import { periodSourceTable } from "./billing.service"; + +/** + * A closed year is imported as its own tagged set of rows rather than being + * identified by date. The tag is written by migration/transform_transactions.py + * and read by BillingService.statement, the edo-cuenta-datos report, and the + * PHP portal — three places that must agree on the exact string. + */ +describe("periodSourceTable", () => { + it("names the archive the migration writes", () => { + expect(periodSourceTable(2025)).toBe("datos2@2025"); + expect(periodSourceTable(2024)).toBe("datos2@2024"); + }); + + it("stays distinct from the live ledger's own table", () => { + // The live table is plain `datos2`. legacyId is a positional ordinal that + // restarts at 0 in every archive, so a shared name would collide with the + // current year row-for-row on the unique key. + expect(periodSourceTable(2025)).not.toBe("datos2"); + expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true); + }); + + it("is not matched by the statement's cash-source exclusion list", () => { + // STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce + // legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must + // survive that filter or a prior year renders empty. + const excluded = [ + "EFECTIVO", + "EFECTIVO_BACKUP", + "EFECTIVO FM3", + "CHEQUE FM3", + "IVA 2015", + ]; + expect(excluded).not.toContain(periodSourceTable(2025)); + }); +}); diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 523d245..df7af4a 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -15,7 +15,10 @@ */ import { Prisma } from "@jorgecuadros/database"; -import { BALANCE_FORWARD_TYPE } from "../billing/billing.service"; +import { + BALANCE_FORWARD_TYPE, + periodSourceTable, +} from "../billing/billing.service"; import { intParam, NOT_VOIDED, @@ -759,6 +762,15 @@ const edoCuentaDatos: ReportDef = { format: "statement", params: [ { key: "customerId", label: "Cliente", kind: "customer-picker" }, + // Which period to print. Blank means the year in progress; an earlier year + // prints from its imported archive, the same source the on-screen + // statement reads. + { + key: "year", + label: "Periodo (año)", + kind: "number", + placeholder: "año en curso", + }, ], columns: [ // Statement rows carry synthetic `__kind` discriminators instead of @@ -804,28 +816,43 @@ const edoCuentaDatos: ReportDef = { select: { transactionDate: true }, }); + // Which period to print. An earlier year comes from its imported archive, + // tagged rather than dated, exactly as the on-screen statement reads it. + const thisYear = new Date().getUTCFullYear(); + const askedYear = Number(p.year); + const requestedYear = + Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear; + const isArchive = requestedYear !== thisYear; + 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", - "EFECTIVO_BACKUP", - "EFECTIVO FM3", - "CHEQUE FM3", - "IVA 2015", + ...(isArchive + ? // The archive is one period's ledger already, so the tag is the + // whole filter and the balance floor must not apply — the floor + // hides exactly the history this period is asking for. + { legacySourceTable: periodSourceTable(requestedYear) } + : { + ...(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: { @@ -847,8 +874,12 @@ const edoCuentaDatos: ReportDef = { // 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(); + // An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE + // FORWARD row is the carry, printed like legacy printed it. + const yearStart = isArchive + ? new Date(0) + : new Date(Date.UTC(requestedYear, 0, 1)); + const year = requestedYear; const running = new Map(); const opening = new Map(); diff --git a/apps/web/src/app/estado-cuenta/[id]/page.tsx b/apps/web/src/app/estado-cuenta/[id]/page.tsx index a66e766..95fcb60 100644 --- a/apps/web/src/app/estado-cuenta/[id]/page.tsx +++ b/apps/web/src/app/estado-cuenta/[id]/page.tsx @@ -40,8 +40,10 @@ import type { * 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. + * Like the legacy EDO CUENTA report, the table covers one calendar year and runs + * oldest-first, opening on the balance carried in from before it. The period + * selector switches years; earlier ones are served from the imported archive of + * that year, which is how legacy kept them — one table per closed year. */ export default function EstadoCuentaDetailPage({ params, @@ -67,19 +69,27 @@ function StatementView({ id }: { id: string }) { const [currency, setCurrency] = useState(null); const [domain, setDomain] = useState(""); + /** null = the current period; the API decides what that is. */ + const [year, setYear] = useState(null); function reload() { let alive = true; setLoading(true); setError(null); - getStatement(id) + getStatement(id, year ?? undefined) .then((d) => { if (!alive) return; setData(d); // Default to the currency the customer actually moves the most in; - // preserve a previously-chosen currency across reloads. + // preserve a previously-chosen currency across reloads — but only if + // the loaded period still has it. Switching to a year the customer + // never moved dollars in would otherwise leave the picker on USD with + // no matching option, showing an empty table for a year that has rows. const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0]; - setCurrency((prev) => prev ?? busiest?.currency ?? "MXN"); + const fallback = busiest?.currency ?? "MXN"; + setCurrency((prev) => + prev && d.summary.some((s) => s.currency === prev) ? prev : fallback, + ); setLoading(false); }) .catch((e) => { @@ -101,7 +111,7 @@ function StatementView({ id }: { id: string }) { getBillingFacets().then(setFacets).catch(() => setFacets(null)); return cleanup; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, year]); const movements = useMemo(() => { if (!data || !currency) return []; @@ -229,6 +239,26 @@ function StatementView({ id }: { id: string }) { )}
+ {/* Only the periods this customer has. A year with no archive would + render an empty table that reads as "no hubo movimientos" when the + truth is that the year was never imported. */} + {data.availableYears.length > 1 && ( + + )}