BALANCE FORWARD rows are not movements. Access materialized one per customer per year, dated Jan 1, holding the closing balance of everything before it — that is what let the portal keep each year in its own table and still show a correct running balance from one year's rows. The platform imported those rows AND the real pre-cutover history they summarize, and every balance aggregate summed the lot. NUMid 501 read -10,469.29 on the receivables worklist against -14,065.29 on the customer's own statement and on the legacy portal; the gap was two cash receipts from 2009 and 2012 that the 2026 opening balance had already absorbed. The scale settles what it is: summed the old way the whole book came to +20,605,447.86 MXN — the office owing its customers 20.6 million pesos. Floored, it is -56,855.90. A receivables ledger cannot be 20M in credit. Adds BALANCE_FLOOR_JOIN + NOT_SUPERSEDED and applies them to balances() (page and count queries, which must agree), to stats()'s per-currency and per-domain figures, and to the owing/in-credit split. The four stats() aggregates moved from Prisma groupBy to raw SQL because groupBy cannot express a per-customer floor. statement() takes the same floor as a scalar, which is also what stops FEE ANUAL and fee15 leaking in. Those are not in STATEMENT_EXCLUDED_SOURCE_TABLES — that list reproduces legacy's DATOS2-only `datosfreak` — and they were putting 2,092 pre-cutover fee rows across 1,062 customers into the statement, skewing it by -5,129,764 against the number those customers have been quoted for years. Dating rather than source is the right test: a fee row *after* the opening balance is a real charge and still counts. movements() is deliberately left alone. It is a browser over captured rows — "how much water did we capture in April" — and staff need the historical rows visible, so it keeps totalling everything, the same asymmetry NOT_OUTSTANDING already has. stats() now separates the two questions it was mixing: movements, ledgerCustomers, crossLineCustomers and the date range stay unfloored inventory; everything under byCurrency/byDomain is a balance and is floored. BillingService had no tests. Adds 13 covering the floor's failure modes — it fails silently, so MIN-vs-MAX, `>` vs `>=`, the NULL branch for customers with no opening balance, and the join/predicate alias pairing are each pinned, plus the 501 arithmetic as a regression. Verified through the real service against the live ledger: balances() and statement() both return -14,065.29 for 501, matching the portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.4 KiB
TypeScript
181 lines
6.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 },
|
|
}),
|
|
);
|
|
});
|
|
|
|
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({
|
|
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(findMany.mock.calls[0][0].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 = findMany.mock.calls[0][0].where;
|
|
expect(where.OR).toEqual([
|
|
{ legacySourceTable: null },
|
|
{ legacySourceTable: { notIn: expect.arrayContaining(["EFECTIVO"]) } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|