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>
204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
import { Prisma } from "@jorgecuadros/database";
|
|
import {
|
|
BALANCE_FLOOR_JOIN,
|
|
BALANCE_FORWARD_TYPE,
|
|
BillingService,
|
|
NOT_SUPERSEDED,
|
|
} from "./billing.service";
|
|
|
|
/**
|
|
* The balance floor drops rows a later BALANCE FORWARD already accounts for.
|
|
*
|
|
* It is worth testing because it fails silently: nothing throws, the numbers are
|
|
* just wrong, and they were wrong for years — the whole book read +20.6M MXN in
|
|
* credit because every customer's pre-cutover history was counted twice, once
|
|
* inside their opening balance and once as itself.
|
|
*/
|
|
describe("balance floor", () => {
|
|
describe("SQL fragments", () => {
|
|
it("binds the type name rather than interpolating it", () => {
|
|
// A literal would be a second place to edit if the label ever changes,
|
|
// and this string reaches SQL from a module constant.
|
|
expect(BALANCE_FLOOR_JOIN.values).toEqual([BALANCE_FORWARD_TYPE]);
|
|
});
|
|
|
|
it("keys the floor to the row's own customer", () => {
|
|
// Without this the derived table cross-joins and every customer inherits
|
|
// the earliest BALANCE FORWARD in the book.
|
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(
|
|
"bfloor ON bfloor.customerId = t.customerId",
|
|
);
|
|
});
|
|
|
|
it("takes the most recent opening balance, not the first", () => {
|
|
// A customer accumulates one BALANCE FORWARD per year. MIN would floor at
|
|
// the oldest and leave every intervening year double-counted.
|
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("MAX(bf.transactionDate)");
|
|
expect(BALANCE_FLOOR_JOIN.sql).not.toContain("MIN(bf.transactionDate)");
|
|
});
|
|
|
|
it("ignores voided opening balances when locating the floor", () => {
|
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("bf.voidedAt IS NULL");
|
|
});
|
|
|
|
it("is inclusive of the opening balance row itself", () => {
|
|
// `>` instead of `>=` would drop the carried balance and understate every
|
|
// customer by exactly that amount.
|
|
expect(NOT_SUPERSEDED.sql).toContain("t.transactionDate >= bfloor.floorDate");
|
|
expect(NOT_SUPERSEDED.sql).not.toMatch(/transactionDate\s*>\s*bfloor/);
|
|
});
|
|
|
|
it("leaves customers with no opening balance untouched", () => {
|
|
// NULL comparisons are never true, so without the explicit IS NULL branch
|
|
// a customer who has no BALANCE FORWARD row loses their entire ledger.
|
|
expect(NOT_SUPERSEDED.sql).toContain("bfloor.floorDate IS NULL");
|
|
});
|
|
|
|
it("only ever references the alias the join defines", () => {
|
|
// The predicate is useless without the join; pairing them wrongly is a
|
|
// runtime "unknown column", so keep the alias identical in both.
|
|
const aliases = NOT_SUPERSEDED.sql.match(/bfloor\.\w+/g) ?? [];
|
|
expect(aliases.length).toBeGreaterThan(0);
|
|
for (const ref of aliases) {
|
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(ref.split(".")[1]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("statement()", () => {
|
|
/**
|
|
* One customer means one floor date, so the statement uses a scalar lookup
|
|
* instead of the join. Asserting on the `where` Prisma is handed is the only
|
|
* way to see it without a database.
|
|
*/
|
|
function serviceWith(floor: Date | null) {
|
|
const findMany = jest.fn().mockResolvedValue([]);
|
|
const prisma = {
|
|
customer: {
|
|
findUnique: jest.fn().mockResolvedValue({
|
|
id: "c1",
|
|
name: "CUADROS, JORGE H.",
|
|
preferredCurrency: "USD",
|
|
_count: { properties: 0, policies: 0 },
|
|
}),
|
|
},
|
|
transaction: {
|
|
findFirst: jest
|
|
.fn()
|
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
|
findMany,
|
|
},
|
|
};
|
|
|
|
return {
|
|
service: new BillingService(prisma as never),
|
|
prisma,
|
|
findMany,
|
|
};
|
|
}
|
|
|
|
it("looks the floor up from the customer's newest opening balance", async () => {
|
|
const { service, prisma } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
|
|
|
await service.statement("c1");
|
|
|
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
customerId: "c1",
|
|
voidedAt: null,
|
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
|
},
|
|
orderBy: { transactionDate: "desc" },
|
|
select: { transactionDate: true },
|
|
}),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 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(rowsQuery(findMany).where).toMatchObject({
|
|
customerId: "c1",
|
|
transactionDate: { gte: floor },
|
|
});
|
|
});
|
|
|
|
it("applies no date bound when the customer has no opening balance", async () => {
|
|
const { service, findMany } = serviceWith(null);
|
|
|
|
await service.statement("c1");
|
|
|
|
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
|
});
|
|
|
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
|
// The two guards answer different questions — one reproduces legacy's
|
|
// DATOS2-only materialization, the other drops superseded history — and
|
|
// dropping either one changes the customer's balance.
|
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
|
|
|
await service.statement("c1");
|
|
|
|
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([
|
|
{
|
|
OR: [
|
|
{ legacySourceTable: null },
|
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
|
{ transactionDate: { lt: expect.any(Date) } },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("regression: NUMid 501", () => {
|
|
/**
|
|
* The arithmetic that exposed the bug, pinned so it cannot silently return.
|
|
* Figures measured against the live ledger on 2026-08-05.
|
|
*/
|
|
const openingBalance = new Prisma.Decimal("-6732.29");
|
|
const activitySinceOpening = new Prisma.Decimal("-7333.00");
|
|
const preCutoverCashAlreadyInOpening = new Prisma.Decimal("3596.00");
|
|
|
|
it("matches the legacy portal once superseded rows are dropped", () => {
|
|
expect(openingBalance.plus(activitySinceOpening).toFixed(2)).toBe(
|
|
"-14065.29",
|
|
);
|
|
});
|
|
|
|
it("reproduces the wrong figure when they are not", () => {
|
|
expect(
|
|
openingBalance
|
|
.plus(activitySinceOpening)
|
|
.plus(preCutoverCashAlreadyInOpening)
|
|
.toFixed(2),
|
|
).toBe("-10469.29");
|
|
});
|
|
});
|
|
});
|