fix(statements): an archive is history below the year start, not nothing
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m20s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 17:29:08 -07:00
co-authored by Claude Opus 5
parent 5549a1e0cf
commit aa5867c8ea
5 changed files with 120 additions and 57 deletions
+11 -8
View File
@@ -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) } },
],
},
]);
});
+39 -19
View File
@@ -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,15 +843,35 @@ 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.
},
},
],
// 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 } },
],
},
],
}),
},
@@ -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<string, Prisma.Decimal>();
/** Balance carried into `yearStart`, per currency. */
const opening = new Map<string, Prisma.Decimal>();
@@ -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: [
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: [
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) } },
]);
});
});
+32 -8
View File
@@ -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 },
+17 -3
View File
@@ -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" }],