diff --git a/apps/api/src/billing/balance-floor.spec.ts b/apps/api/src/billing/balance-floor.spec.ts index 837b3c3..3dd23b8 100644 --- a/apps/api/src/billing/balance-floor.spec.ts +++ b/apps/api/src/billing/balance-floor.spec.ts @@ -149,18 +149,15 @@ describe("balance floor", () => { }); 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. + // The three guards answer different questions — one windows imported + // periods, one drops the cash receipt book the ledger already posts, the + // floor drops superseded history — and dropping any one of them 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). @@ -172,6 +169,20 @@ describe("balance floor", () => { { transactionDate: { lt: expect.any(Date) } }, ], }, + // The cash journal, qualified by the database it came from — the + // insurance line has its own EFECTIVO and that one is a real ledger. + { + OR: [ + { legacySourceDb: null }, + { legacySourceDb: { not: "UTILITIES" } }, + { legacySourceTable: null }, + { + legacySourceTable: { + notIn: expect.arrayContaining(["EFECTIVO"]), + }, + }, + ], + }, ]); }); }); diff --git a/apps/api/src/billing/billing.service.ts b/apps/api/src/billing/billing.service.ts index a08eda2..e78be97 100644 --- a/apps/api/src/billing/billing.service.ts +++ b/apps/api/src/billing/billing.service.ts @@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql` */ export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`; -/** - * Source tables excluded from the customer-facing statement. - * - * The legacy portal's `datosfreak` table was materialized from DATOS2 only - * (`objects.json:1358`), so the customer's "current balance" never saw - * EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor - * the IVA 2015 snapshot. The unified `transactions` table has all of them, so - * the statement must drop them to match the legacy number the customer has - * been quoted for years. The staff-facing balances worklist and movement - * browser keep them — they're real money, just tracked separately - * (FM3 = visa fee stream, EFECTIVO = cash receipt stream). - */ /** * `legacySourceTable` of an imported prior period. * @@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`; /** Matches any imported period tag, for discovering which years a customer has. */ export const PERIOD_TABLE_PREFIX = "datos2@"; -export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ +/** + * The legacy cash receipt book — a journal, not a ledger. + * + * `EFECTIVO` is the office's numbered receipt pad: money is handed over the + * counter, a folio is written, and the same receipt is then *posted* to the + * utilities ledger (`DATOS2`) as reference `C`. Legacy summed the ledger + * alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is + * materialized from DATOS2 only (`objects.json:1358`). The migration flattened + * both tables into one `transactions` table, so anything summing a customer's + * rows counts every cash receipt twice. + * + * Verified against the live legacy database on 2026-08-20: of the 297 receipts + * written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL + * 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather + * than a code change. Six receipts post converted to pesos under a mistyped + * folio, which is why matching on folio and amount found fewer duplicate pairs + * than actually exist — a reason to exclude the whole journal rather than to + * exclude a list of confirmed pairs. + * + * THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table + * also called `EFECTIVO`, and that one is the insurance line's *only* ledger — + * nothing posts it anywhere else. Excluding by table name alone erases the + * whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102 + * customers, 99 of whom have no other rows at all. + */ +export const CASH_JOURNAL_SOURCE_DB = "UTILITIES"; + +export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [ "EFECTIVO", "EFECTIVO_BACKUP", "EFECTIVO FM3", @@ -243,6 +258,53 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ "IVA 2015", ]; +/** + * Prisma form of the cash-journal exclusion. + * + * Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`, + * and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row + * (no `legacySourceTable`) would silently vanish. Same for the database test. + */ +export const notCashJournal = (): Prisma.TransactionWhereInput => ({ + OR: [ + { legacySourceDb: null }, + { legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } }, + { legacySourceTable: null }, + { legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } }, + ], +}); + +/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */ +export const NOT_CASH_JOURNAL = Prisma.sql`( + t.legacySourceDb IS NULL + OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB} + OR t.legacySourceTable IS NULL + OR t.legacySourceTable NOT IN (${Prisma.join([ + ...CASH_JOURNAL_SOURCE_TABLES, + ])}))`; + +/** + * Keeps an imported prior period out of the *current* period, NULL-safely. + * + * The balance floor does not settle the archives on its own, in both + * directions. A customer whose newest BALANCE FORWARD lives *inside* an archive + * floors at that archive's own January 1st, so every row of it clears the floor + * — and that is correct, because below the year start the archive is the only + * carry there is. 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 for the same NULL reason as above. + */ +export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`( + t.legacySourceTable IS NULL + OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`} + OR t.transactionDate < ${yearStart})`; + +/** January 1st of the running year, UTC — the current period's lower bound. */ +export const currentYearStart = () => + new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); + @Injectable() export class BillingService { constructor(private readonly prisma: PrismaService) {} @@ -412,6 +474,16 @@ export class BillingService { async balances(params: BalanceParams) { const { query, page, pageSize, currency, balance, domain, sort } = params; + // A balance is what the customer owes, so it takes the same rules the + // statement takes: the floor, the cash journal, and the archive window. + // Without the last two the worklist quoted a different number than the + // customer's own statement — NUMid 295 read 14,377.46 against a statement + // of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in + // each case being a cash receipt already posted to the ledger. + const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( + currentYearStart(), + )}`; + const filters: Prisma.Sql[] = []; if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); const txFilter = filters.length @@ -474,7 +546,7 @@ export class BillingService { FROM customers c JOIN transactions t ON t.customerId = c.id ${BALANCE_FLOOR_JOIN} - WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} + WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter} GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state ${having} ${orderBy} @@ -490,7 +562,7 @@ export class BillingService { -- Must match the page query's filters exactly, or the total disagrees -- with the rows. (The void exclusion was missing here before the -- outstanding work; a voided-only customer inflated the count.) - WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} + WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter} GROUP BY c.id ${having} ) x @@ -537,11 +609,22 @@ export class BillingService { * Two different questions live here and they use different row sets. * `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are * INVENTORY — what is stored — and count everything not voided. Everything - * under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED - * and drops rows an opening balance already accounts for. The four aggregates - * moved from Prisma groupBy to raw SQL to express that join; groupBy cannot. + * under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope + * `balances()` takes — the opening-balance floor, the cash journal and the + * archive window — and the book has to agree with the worklist that sits + * under it. The four aggregates moved from Prisma groupBy to raw SQL to + * express that join; groupBy cannot. + * + * KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding + * rows, while `balances()` does. Reconciling them moves the book by about + * 1.95M MXN and turns on whether an unfunded charge is owed by the customer, + * which is the client's call and not settled yet. */ async stats() { + const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( + currentYearStart(), + )}`; + const [movements, ledgerCustomers] = await Promise.all([ this.prisma.transaction.count({ where: NOT_VOIDED }), this.prisma.transaction @@ -573,7 +656,7 @@ export class BillingService { SUM(t.amount > 0) AS creditCount FROM transactions t ${BALANCE_FLOOR_JOIN} - WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} + WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope} GROUP BY t.currency `; @@ -589,7 +672,7 @@ export class BillingService { SUM(t.amount) AS net, COUNT(*) AS count FROM transactions t ${BALANCE_FLOOR_JOIN} - WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} + WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope} GROUP BY t.domain, t.currency `; @@ -610,7 +693,7 @@ export class BillingService { SELECT t.customerId, t.currency, SUM(t.amount) AS bal FROM transactions t ${BALANCE_FLOOR_JOIN} - WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} + WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope} GROUP BY t.customerId, t.currency ) x GROUP BY currency @@ -832,20 +915,6 @@ export class BillingService { { legacySourceTable: periodSourceTable(requested) } : { ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), - // NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and - // `NULL NOT IN (...)` is NULL, not true — so every app-captured row - // (which has no legacySourceTable) silently vanished from the - // statement while still showing in the movement browser. Rows the app - // books must appear on the customer's statement, so the null case is - // spelled out. - OR: [ - { legacySourceTable: null }, - { - legacySourceTable: { - notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[], - }, - }, - ], // 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 @@ -872,6 +941,10 @@ export class BillingService { { transactionDate: { lt: yearStart } }, ], }, + // The cash receipt book is the ledger's own postings written a + // second time, so listing it here would show every counter + // payment twice and double the credit side. + notCashJournal(), ], }), }, diff --git a/apps/api/src/billing/cash-journal.spec.ts b/apps/api/src/billing/cash-journal.spec.ts new file mode 100644 index 0000000..b115fd9 --- /dev/null +++ b/apps/api/src/billing/cash-journal.spec.ts @@ -0,0 +1,77 @@ +import { Prisma } from "@jorgecuadros/database"; +import { + CASH_JOURNAL_SOURCE_DB, + CASH_JOURNAL_SOURCE_TABLES, + NOT_CASH_JOURNAL, + notCashJournal, +} from "./billing.service"; + +/** + * `EFECTIVO` is the office's paper receipt book, and every receipt in it is + * also posted to the utilities ledger as `C`. Both copies were imported + * into one `transactions` table, so a balance that reads the journal counts + * each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide, + * and 3,500.00 of it on NUMid 10 alone. + * + * These fail silently in the worst way: the numbers stay plausible, they are + * just too generous to the customer. Two shapes of mistake are easy to make + * here and both are covered below — dropping the database qualifier (which + * erases the insurance line's only ledger) and writing the exclusion as a bare + * `NOT IN` (which erases every app-captured row). + */ +describe("cash journal exclusion", () => { + describe("raw SQL form", () => { + it("binds the source database rather than interpolating it", () => { + expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB); + }); + + it("qualifies the table names with the database they came from", () => { + // `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's + // ONLY ledger — nothing posts it anywhere else. Matching on the table + // name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers. + expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>"); + expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB); + }); + + it("spells both null cases out instead of relying on NOT IN", () => { + // `NULL NOT IN (...)` is NULL, not true. Without these branches every + // app-captured row — the ones staff key in by hand — drops out of the + // balance while still showing in the movement browser. + expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL"); + expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL"); + }); + + it("covers the whole cash family, not just EFECTIVO", () => { + for (const table of CASH_JOURNAL_SOURCE_TABLES) { + expect(NOT_CASH_JOURNAL.values).toContain(table); + } + }); + + it("is a single parenthesised term, safe to AND into a WHERE clause", () => { + // It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed + // OR chain would swallow every condition after it and silently widen the + // whole query to the entire table. + const sql = NOT_CASH_JOURNAL.sql.trim(); + expect(sql.startsWith("(")).toBe(true); + expect(sql.endsWith(")")).toBe(true); + }); + }); + + describe("Prisma form", () => { + it("matches the raw form's terms so the two cannot drift apart", () => { + const branches = notCashJournal().OR as Prisma.TransactionWhereInput[]; + expect(branches).toEqual([ + { legacySourceDb: null }, + { legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } }, + { legacySourceTable: null }, + { legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } }, + ]); + }); + + it("returns a fresh object each call", () => { + // It is spread into `AND: [...]` arrays that Prisma may mutate; a shared + // singleton would leak one query's filters into the next. + expect(notCashJournal()).not.toBe(notCashJournal()); + }); + }); +}); diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index 54bdd04..1af0101 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -5,21 +5,10 @@ import { CreateCustomerDto } from "./create-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto"; import { BALANCE_FORWARD_TYPE, + notCashJournal, PERIOD_TABLE_PREFIX, - STATEMENT_EXCLUDED_SOURCE_TABLES, } from "../billing/billing.service"; -/** - * Keeps imported prior periods out of a query, NULL-safely. - * - * A closed year is imported as its own tagged copy of that year's ledger - * (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every - * peso of it. Anything summing a customer's history has to leave the archives - * out or it counts each closed year twice — see the floor comment below. - * - * `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. - */ /** * Keeps an imported prior period out of the *current* period, NULL-safely. * @@ -225,19 +214,7 @@ export class CustomersService { // 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: [ - archiveIsHistory(yearStart), - { - OR: [ - { legacySourceTable: null }, - { - legacySourceTable: { - notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES], - }, - }, - ], - }, - ], + AND: [archiveIsHistory(yearStart), notCashJournal()], }, _sum: { amount: true }, _count: { _all: true }, diff --git a/apps/api/src/reports/reports.registry.ts b/apps/api/src/reports/reports.registry.ts index 8238294..cc6c1b7 100644 --- a/apps/api/src/reports/reports.registry.ts +++ b/apps/api/src/reports/reports.registry.ts @@ -17,6 +17,7 @@ import { Prisma } from "@jorgecuadros/database"; import { BALANCE_FORWARD_TYPE, + notCashJournal, periodSourceTable, } from "../billing/billing.service"; import { @@ -835,23 +836,6 @@ const edoCuentaDatos: ReportDef = { { legacySourceTable: periodSourceTable(requestedYear) } : { ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), - // NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn` - // drops every app-captured row (they have no legacySourceTable) — the - // same defect this report's on-screen twin was fixed for. - OR: [ - { legacySourceTable: null }, - { - legacySourceTable: { - notIn: [ - "EFECTIVO", - "EFECTIVO_BACKUP", - "EFECTIVO FM3", - "CHEQUE FM3", - "IVA 2015", - ], - }, - }, - ], // 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. @@ -868,6 +852,12 @@ const edoCuentaDatos: ReportDef = { }, ], }, + // The cash receipt book, which the ledger already carries as + // its own `C` postings. Taken from the shared helper + // rather than restated, so the printed statement and the screen + // cannot drift apart — and so this keeps the database + // qualifier that spares the insurance line's own EFECTIVO. + notCashJournal(), ], }), },