diff --git a/scripts/corte-audit.mjs b/scripts/corte-audit.mjs index 7933d98..49f058d 100644 --- a/scripts/corte-audit.mjs +++ b/scripts/corte-audit.mjs @@ -15,14 +15,27 @@ * is a raw lifetime sum. The platform only migrated the CURRENT-year * charge ledger (datos2); the per-year charge tables live in DreamHost * and were never staged. What survives before the cutover is therefore - * the EFECTIVO cash journal — receipts with no matching charges — so - * those sums read as the office owing money it does not owe. + * a cash journal — receipts with no matching charges — so those sums read + * as the office owing money it does not owe. * - * B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in - * EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both - * rows are after the 2026-01-01 floor, so both count. The statement hides - * them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances - * worklist, the movement browser and the /clientes/:id card do not. + * READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period + * import the group is mostly insurance-only customers whose rows come from + * the seguros database's own EFECTIVO, which is that line's ONLY ledger. + * Flooring those deletes receipts instead of removing a double count. The + * "floor them, never carry" argument holds for the utilities rows alone. + * + * B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in + * EFECTIVO with folio `N` and once in datos2 with reference `CN`. + * + * This is NOT an office data-entry defect, which is what it looked like + * while the pair count kept growing at ~40/month. EFECTIVO is the paper + * receipt book and every receipt in it is POSTED to the datos2 ledger by + * design — verified against the live legacy database, 296 of the 297 + * receipts written in 2026 carry a matching posting. Legacy summed the + * ledger alone. The duplication was the migration flattening a journal and + * its postings into one table, and since 1.0.26 the application drops the + * journal from every balance. Section B now reports what the journal holds + * and asserts that none of it still reaches a balance. * * The folio alone neither proves nor disproves a pair, so it is used as a * lead and never as the verdict. Folios are reused, so `C13483` can collide @@ -82,6 +95,44 @@ const BF_PREDICATE = `( AND t.legacySourceTable = 'datos2') )`; +/** + * The rest of what a balance query drops, over and above the floor. Mirrors + * NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit + * that computes a different book than the application is worse than no audit, + * because its numbers look authoritative and diff cleanly against yesterday's. + * + * The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table + * called EFECTIVO and that one is the insurance line's only ledger; matching on + * the table name alone would report 55,444.95 USD of real receivables as + * duplicate cash. See efectivo-is-a-journal-not-a-ledger. + */ +const NOT_CASH_JOURNAL = `( + t.legacySourceDb IS NULL + OR t.legacySourceDb <> 'UTILITIES' + OR t.legacySourceTable IS NULL + OR t.legacySourceTable NOT IN + ('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015') +)`; + +/** + * An imported period counts as history below the year start and is dropped at + * or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL + * for an app-captured row, which would silently drop every one. + * + * The bound is the running calendar year, matching currentYearStart() in the + * application rather than the cutover — the app's current period is "this + * year", whatever cut the data happens to reflect. + */ +const yearStart = `${new Date().getUTCFullYear()}-01-01`; +const ARCHIVE_IS_HISTORY = `( + t.legacySourceTable IS NULL + OR t.legacySourceTable NOT LIKE 'datos2@%' + OR t.transactionDate < '${yearStart}' +)`; + +/** Everything a balance drops apart from the floor itself. */ +const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`; + async function main() { const args = process.argv.slice(2); const limit = Number(arg(args, "--limit", "25")); @@ -127,9 +178,13 @@ async function main() { ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn, ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd, ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) - THEN t.amount ELSE 0 END), 2) AS todayMxn, + THEN t.amount ELSE 0 END), 2) AS floorOnlyMxn, ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) - THEN t.amount ELSE 0 END), 2) AS todayUsd, + THEN t.amount ELSE 0 END), 2) AS floorOnlyUsd, + ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) + AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayMxn, + ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) + AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayUsd, ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ? THEN t.amount ELSE 0 END), 2) AS flooredMxn, ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ? @@ -197,6 +252,39 @@ async function main() { cutover, ); + // What the floorless population's balance is actually MADE OF. + // + // "Floor them, never carry" was written when this group looked like + // utilities cash receipts whose charges were never migrated. It is not that + // any more. After the prior-period import the group is 99 customers, and + // almost all of them are insurance-only — their rows come from the seguros + // database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts + // it a second time, so flooring it does not remove a double count, it + // deletes receipts. Split the two so the remedy is chosen per population + // rather than for the group. + const [floorlessMix] = await prisma.$queryRawUnsafe( + ` + WITH nobf AS ( + SELECT c.id FROM customers c + WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId + WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE} + ) + ) + SELECT + COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts, + ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN' + THEN t.amount ELSE 0 END), 2) AS insMxn, + ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD' + THEN t.amount ELSE 0 END), 2) AS insUsd, + COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts + FROM transactions t JOIN nobf n ON n.id = t.customerId + WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND t.transactionDate < ? + `, + cutover, + ); + // ---- section B: double-booked receipts --------------------------------- const pairs = await prisma.$queryRawUnsafe( ` @@ -269,6 +357,34 @@ async function main() { `, ); + // REGRESSION GUARD. Since 1.0.26 the application drops the whole cash + // journal from every balance, so none of section B's rows should reach one + // any more. This counts the ones that still do: it is 0 while the exclusion + // holds, and goes non-zero the moment someone reintroduces a balance query + // that forgets it. A defect list that cannot tell you whether the defect is + // still live is just history. + const [stillCounted] = await prisma.$queryRawUnsafe( + ` + WITH bfloor AS ( + SELECT t.customerId, MAX(t.transactionDate) AS floorDate + FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId + WHERE t.voidedAt IS NULL AND ${BF_PREDICATE} + GROUP BY t.customerId + ) + SELECT COUNT(*) AS n, + ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn, + ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd + FROM transactions t + LEFT JOIN bfloor b ON b.customerId = t.customerId + WHERE t.voidedAt IS NULL AND t.outstanding = 0 + AND t.legacySourceDb = 'UTILITIES' + AND t.legacySourceTable IN + ('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015') + AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) + AND ${READ_SCOPE} + `, + ); + // ---- CSV escapes ------------------------------------------------------- if (args.includes("--csv-a")) return dumpCsv(floorless); if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]); @@ -276,8 +392,15 @@ async function main() { // ---- report ------------------------------------------------------------ console.log("\nBOOK (voided and outstanding rows excluded)"); console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`); - console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`); + console.log(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`); + console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`); console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`); + console.log( + " (the middle line is the floor alone, kept only so older runs of this\n" + + " script still diff against something. The app has dropped the cash\n" + + " journal and windowed the archives since 1.0.26; USD going to zero on\n" + + " the utilities side is correct, that ledger is peso-denominated.)", + ); const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0); const wouldZero = floorless.filter((r) => d(r.postRows) === 0); @@ -291,6 +414,17 @@ async function main() { console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`); console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` + ` (their balance becomes 0 — an assertion, not a migrated figure)`); + console.log( + ` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` + + ` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` + + `, ${d(floorlessMix.utilCusts)} carry utilities rows`, + ); + console.log( + ` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` + + ` line's only ledger — nothing posts it twice — so flooring those\n` + + ` customers deletes receipts rather than removing a double count.\n` + + ` The argument for flooring holds for the utilities rows alone.`, + ); console.log( `\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` + ` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`, @@ -347,6 +481,16 @@ async function main() { console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`); console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`); console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`); + console.log( + ` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` + + ` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` + + `${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`, + ); + console.log( + ` The rows below still exist and always will; the ledger's own C\n` + + ` posting is the copy that counts. This section is now a record of what\n` + + ` the journal holds, not a list of money being double-counted.`, + ); console.log( `\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` + ` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`, @@ -382,8 +526,9 @@ async function main() { "\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" + "floor, not a carried-forward balance: the pre-cutover charge ledger was\n" + "never migrated, so no true opening balance can be computed from this\n" + - "database. It exists in the DreamHost per-year tables. Section B is an\n" + - "independent defect and does not need a corte to fix.", + "database. It exists in the DreamHost per-year tables. Section B is no\n" + + "longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" + + "that matters there is the regression count, which must stay at 0.", ); } finally { await prisma.$disconnect();