fix(statements): an archive is history below the year start, not nothing
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:
@@ -159,15 +159,18 @@ describe("balance floor", () => {
|
|||||||
const where = rowsQuery(findMany).where;
|
const where = rowsQuery(findMany).where;
|
||||||
expect(where.OR).toEqual([
|
expect(where.OR).toEqual([
|
||||||
{ legacySourceTable: null },
|
{ 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: {
|
OR: [
|
||||||
notIn: expect.arrayContaining(["EFECTIVO"]),
|
{ legacySourceTable: null },
|
||||||
// Imported prior periods are excluded here too. The floor does not
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
// cover them: a customer whose newest BALANCE FORWARD sits inside
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
// 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@" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -784,6 +784,19 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
const isArchive = requested !== thisYear;
|
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
|
// One customer, so the balance floor is a single date rather than the
|
||||||
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||||
// the opening balance are already inside it, and showing them would both
|
// the opening balance are already inside it, and showing them would both
|
||||||
@@ -830,16 +843,36 @@ export class BillingService {
|
|||||||
{
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
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" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
@@ -867,19 +900,6 @@ export class BillingService {
|
|||||||
// opening balance), the earlier rows still have to be *counted* or every
|
// opening balance), the earlier rows still have to be *counted* or every
|
||||||
// balance below is wrong, so they are folded into `opening` rather than
|
// 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.
|
// 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>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
/** Balance carried into `yearStart`, per currency. */
|
/** Balance carried into `yearStart`, per currency. */
|
||||||
const opening = new Map<string, Prisma.Decimal>();
|
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");
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes imported periods from the totals", async () => {
|
it("counts an archive as history but never as current", async () => {
|
||||||
// The floor alone is not enough: it does not exist for the floorless, and
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
// archives carry rows dated past their own period that clear it.
|
// 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"));
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
await service.detail("c1");
|
await service.detail("c1");
|
||||||
|
|
||||||
const and = groupBy.mock.calls[0][0].where.AND;
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
expect(and).toEqual(
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
expect.arrayContaining([
|
JSON.stringify(c).includes("datos2@"),
|
||||||
{
|
|
||||||
OR: [
|
|
||||||
{ legacySourceTable: null },
|
|
||||||
{ legacySourceTable: { not: { startsWith: "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 () => {
|
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");
|
await service.detail("c1");
|
||||||
|
|
||||||
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
expect(include.transactions.where).toMatchObject({
|
expect(include.transactions.where.OR).toEqual([
|
||||||
OR: [
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
{ 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) } },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,12 +20,29 @@ import {
|
|||||||
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which
|
* `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.
|
* 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: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -152,7 +169,15 @@ export class CustomersService {
|
|||||||
// in 2026, datos2@2025 two more — so a date test alone would surface
|
// 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
|
// a closed year's rows in the current year's list, duplicating the
|
||||||
// live ledger's own copy of them for three customers.
|
// 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" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
@@ -197,12 +222,11 @@ export class CustomersService {
|
|||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
outstanding: false,
|
outstanding: false,
|
||||||
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// The floor alone would leave the archives out for anyone who has an
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
// opening balance, but 102 customers have none — for them there is no
|
// an archive clears it with every row of that archive, and the rows
|
||||||
// floor at all, and the archives' rows dated past their own period
|
// archives spill into the following January clear any floor.
|
||||||
// clear it even for the rest.
|
|
||||||
AND: [
|
AND: [
|
||||||
EXCLUDE_ARCHIVES,
|
archiveIsHistory(yearStart),
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: null },
|
||||||
|
|||||||
@@ -849,12 +849,26 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
"CHEQUE FM3",
|
"CHEQUE FM3",
|
||||||
"IVA 2015",
|
"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" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
|
|||||||
Reference in New Issue
Block a user