From aa5867c8ea41ae63e3668c45522f21d931aef913 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Wed, 19 Aug 2026 17:28:59 -0700 Subject: [PATCH] fix(statements): an archive is history below the year start, not nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c6feae9 excluded imported periods from the current period outright. That is right for the rows an archive spills into the following January — those already sit inside the next year's BALANCE FORWARD, which is the sum of the whole archive, so counting them again would double-book them and file a closed year's row as current. It is wrong for everything below the year start. Those rows are what `opening` exists for, and for a customer whose newest BALANCE FORWARD lives *inside* an archive they are the only carry there is: the corte skipped NUMid 295 in 2026, so his floor is the archive's own January 1 and excluding it dropped his entire 2025 closing balance. His statement read 3,592.00 against a true 4,377.46. So the rule is a window, not an exclusion: an archive row counts below the year start and never at or above it. Applied identically to statement(), the edo-cuenta-datos report and the customer-file card, which have to agree. Spelled as a positive OR rather than NOT(tag AND date). `NOT (col LIKE '...' AND ...)` is NULL for a row with no legacySourceTable, so the negated form would have silently dropped every app-captured movement — the same NULL trap the source-table exclusion was already fixed for. Verified against prod, which now carries datos2@2025: exactly one customer is affected and the book moves by his 785.46. NUMid 501 is unchanged at -10,874.33 and still matches the portal; 6 and 173 are unchanged to the cent; the two customers whose archives spill into January 2026 still keep those rows out of the current period. Co-Authored-By: Claude Opus 5 --- apps/api/src/billing/balance-floor.spec.ts | 19 +++--- apps/api/src/billing/billing.service.ts | 60 ++++++++++++------- .../customers/customer-ledger-card.spec.ts | 38 ++++++------ apps/api/src/customers/customers.service.ts | 40 ++++++++++--- apps/api/src/reports/reports.registry.ts | 20 ++++++- 5 files changed, 120 insertions(+), 57 deletions(-) diff --git a/apps/api/src/billing/balance-floor.spec.ts b/apps/api/src/billing/balance-floor.spec.ts index 5113dd6..837b3c3 100644 --- a/apps/api/src/billing/balance-floor.spec.ts +++ b/apps/api/src/billing/balance-floor.spec.ts @@ -159,15 +159,18 @@ describe("balance floor", () => { const where = rowsQuery(findMany).where; expect(where.OR).toEqual([ { legacySourceTable: null }, + { legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } }, + ]); + // Imported periods are windowed rather than excluded: history below the + // year start (the only carry a floored-by-archive customer has), never + // at or above it (those rows sit inside the next BALANCE FORWARD). + expect(where.AND).toEqual([ { - legacySourceTable: { - notIn: expect.arrayContaining(["EFECTIVO"]), - // Imported prior periods are excluded here too. The floor does not - // cover them: a customer whose newest BALANCE FORWARD sits inside - // an archive floors at that archive's own Jan 1, and every archive - // spills a row or two into the following January. - not: { startsWith: "datos2@" }, - }, + OR: [ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + { transactionDate: { lt: expect.any(Date) } }, + ], }, ]); }); diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index e7aab71..a08eda2 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -784,6 +784,19 @@ export class BillingService { } const isArchive = requested !== thisYear; + // + // 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)); + // 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 @@ -830,16 +843,36 @@ export class BillingService { { legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], - // The archives have to go too, and the floor will not do - // it. A customer whose newest BALANCE FORWARD lives inside - // an archive floors at that archive's own Jan 1, so all 28 - // of its rows clear it; and the archives spill rows into - // the following January, which clears any floor. Both put a - // closed year back into the current one. - not: { startsWith: PERIOD_TABLE_PREFIX }, }, }, ], + // An archive row belongs to this period only as history. Below + // the year start it is exactly what `opening` is for, and for the + // one customer whose newest BALANCE FORWARD lives *inside* an + // archive it is the only carry there is — dropping it outright + // understated NUMid 295 by his whole 2025 closing balance, 785.46. + // + // At or above the year start it must go. The archives spill a + // couple of rows into the following January, and those are + // already inside the next year's BALANCE FORWARD (which is the + // sum of the whole archive), so listing them here would both + // double-count and file a closed year's row as current. + // + // Spelled as a positive OR because `NOT (col LIKE ... AND ...)` + // is NULL for an app-captured row, which would drop every one. + AND: [ + { + OR: [ + { legacySourceTable: null }, + { + legacySourceTable: { + not: { startsWith: PERIOD_TABLE_PREFIX }, + }, + }, + { transactionDate: { lt: yearStart } }, + ], + }, + ], }), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], @@ -867,19 +900,6 @@ 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. - // - // 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. */ const opening = new Map(); diff --git a/apps/api/src/customers/customer-ledger-card.spec.ts b/apps/api/src/customers/customer-ledger-card.spec.ts index 06f451a..1e5579e 100644 --- a/apps/api/src/customers/customer-ledger-card.spec.ts +++ b/apps/api/src/customers/customer-ledger-card.spec.ts @@ -60,24 +60,25 @@ describe("customer file ledger card", () => { expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate"); }); - it("excludes imported periods from the totals", async () => { - // The floor alone is not enough: it does not exist for the floorless, and - // archives carry rows dated past their own period that clear it. + it("counts an archive as history but never as current", async () => { + // The floor alone is not enough: a customer floored by an archive clears + // it with every row of that archive, and the rows archives spill into the + // following January clear any floor. But excluding archives outright is + // wrong too — below the year start they are the only carry a + // floored-by-archive customer has (NUMid 295, 785.46). const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z")); await service.detail("c1"); const and = groupBy.mock.calls[0][0].where.AND; - expect(and).toEqual( - expect.arrayContaining([ - { - OR: [ - { legacySourceTable: null }, - { legacySourceTable: { not: { startsWith: "datos2@" } } }, - ], - }, - ]), + const rule = and.find((c: { OR?: unknown[] }) => + JSON.stringify(c).includes("datos2@"), ); + expect(rule.OR).toEqual([ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + { transactionDate: { lt: expect.any(Date) } }, + ]); }); it("keeps the cash-source exclusion so it reads like the statement", async () => { @@ -110,11 +111,12 @@ describe("customer file ledger card", () => { await service.detail("c1"); const include = prisma.customer.findUnique.mock.calls[0][0].include; - expect(include.transactions.where).toMatchObject({ - OR: [ - { legacySourceTable: null }, - { legacySourceTable: { not: { startsWith: "datos2@" } } }, - ], - }); + expect(include.transactions.where.OR).toEqual([ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + // Nothing below yearStart reaches this list, so the third branch never + // admits an archive row here — it is carried for one shared rule. + { transactionDate: { lt: expect.any(Date) } }, + ]); }); }); diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 50d0d20..54bdd04 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -20,12 +20,29 @@ import { * `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which * carry no legacySourceTable) need the null branch spelled out or they vanish. */ -const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = { +/** + * Keeps an imported prior period out of the *current* period, NULL-safely. + * + * A closed year is imported as its own tagged copy (`datos2@2025`). Below the + * year start it is history and counts — for the one customer whose newest + * BALANCE FORWARD lives inside an archive it is the only carry there is, and + * dropping it understated NUMid 295 by his entire 2025 closing balance. At or + * above the year start it must go: the archives spill a couple of rows into the + * following January, and those already sit inside the next year's BALANCE + * FORWARD, which is the sum of the whole archive. + * + * Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to + * NULL for an app-captured row (no legacySourceTable), dropping every one. + */ +const archiveIsHistory = ( + yearStart: Date, +): Prisma.TransactionWhereInput => ({ OR: [ { legacySourceTable: null }, { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } }, + { transactionDate: { lt: yearStart } }, ], -}; +}); export interface ListParams { query?: string; @@ -152,7 +169,15 @@ export class CustomersService { // in 2026, datos2@2025 two more — so a date test alone would surface // a closed year's rows in the current year's list, duplicating the // live ledger's own copy of them for three customers. - where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES }, + // Archives are kept out by tag, not by date. They are not cleanly + // bounded — datos2@2025 carries rows dated into 2026 — so a date test + // alone would surface a closed year's rows in the current year's + // list. Nothing below yearStart reaches this list anyway, so the + // window rule reduces to a plain exclusion here. + where: { + transactionDate: { gte: yearStart }, + ...archiveIsHistory(yearStart), + }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }], include: { type: true }, }, @@ -197,12 +222,11 @@ export class CustomersService { voidedAt: null, outstanding: false, ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), - // The floor alone would leave the archives out for anyone who has an - // opening balance, but 102 customers have none — for them there is no - // floor at all, and the archives' rows dated past their own period - // clear it even for the rest. + // The floor alone does not settle the archives: a customer floored by + // an archive clears it with every row of that archive, and the rows + // archives spill into the following January clear any floor. AND: [ - EXCLUDE_ARCHIVES, + archiveIsHistory(yearStart), { OR: [ { legacySourceTable: null }, diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 7398874..8238294 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -849,12 +849,26 @@ const edoCuentaDatos: ReportDef = { "CHEQUE FM3", "IVA 2015", ], - // Archives out of the current period too — the balance - // floor does not exclude them (see the on-screen twin). - not: { startsWith: "datos2@" }, }, }, ], + // Archive rows count as history below the year start (that is + // what `opening` is for, and for a customer floored by an archive + // it is the only carry there is) and are dropped at or above it. + // Same rule as the on-screen twin — see BillingService.statement. + AND: [ + { + OR: [ + { legacySourceTable: null }, + { legacySourceTable: { not: { startsWith: "datos2@" } } }, + { + transactionDate: { + lt: new Date(Date.UTC(requestedYear, 0, 1)), + }, + }, + ], + }, + ], }), }, orderBy: [{ transactionDate: "asc" }, { id: "asc" }],