fix(audit): the corte audit computed a book the app no longer shows

The script carried its own copy of the balance rules, and since 1.0.26 that
copy was stale: it reported 631,078.98 MXN where the application reports
-416,403.42. An audit that disagrees with production is worse than no audit,
because its numbers look authoritative and diff cleanly against yesterday's
run. It now mirrors NOT_CASH_JOURNAL and archiveIsHistorySql, keeps the
floor-only figure as a labelled line so older runs still diff, and says
plainly which line is the app's.

Section B stops being an open defect list. EFECTIVO is a receipt book whose
receipts are posted to the datos2 ledger by design, so those rows exist and
always will; what matters is whether any of them still reaches a balance.
That is now a counted assertion which must stay at 0, and it fails loudly if
someone writes a balance query that forgets the exclusion.

Section A grew the line that changes its recommendation. "Floor them, never
carry" was written when the floorless group looked like utilities receipts
whose charges were never migrated. Measured on production today: of the 99,
ninety-five carry insurance-line cash and NONE carry utilities rows. The
seguros EFECTIVO is that line's only ledger, so flooring them deletes
receipts rather than removing a double count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 20:05:47 -07:00
co-authored by Claude Opus 5
parent f7507f2370
commit c4213aa697
+157 -12
View File
@@ -15,14 +15,27 @@
* is a raw lifetime sum. The platform only migrated the CURRENT-year * is a raw lifetime sum. The platform only migrated the CURRENT-year
* charge ledger (datos2); the per-year charge tables live in DreamHost * charge ledger (datos2); the per-year charge tables live in DreamHost
* and were never staged. What survives before the cutover is therefore * and were never staged. What survives before the cutover is therefore
* the EFECTIVO cash journal — receipts with no matching charges — so * a cash journal — receipts with no matching charges — so those sums read
* those sums read as the office owing money it does not owe. * as the office owing money it does not owe.
* *
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in * READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both * import the group is mostly insurance-only customers whose rows come from
* rows are after the 2026-01-01 floor, so both count. The statement hides * the seguros database's own EFECTIVO, which is that line's ONLY ledger.
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances * Flooring those deletes receipts instead of removing a double count. The
* worklist, the movement browser and the /clientes/:id card do not. * "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 * 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 * lead and never as the verdict. Folios are reused, so `C13483` can collide
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
AND t.legacySourceTable = 'datos2') 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() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const limit = Number(arg(args, "--limit", "25")); 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='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='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) 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) 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 >= ? ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS flooredMxn, THEN t.amount ELSE 0 END), 2) AS flooredMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ? ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
@@ -197,6 +252,39 @@ async function main() {
cutover, 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 --------------------------------- // ---- section B: double-booked receipts ---------------------------------
const pairs = await prisma.$queryRawUnsafe( 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 ------------------------------------------------------- // ---- CSV escapes -------------------------------------------------------
if (args.includes("--csv-a")) return dumpCsv(floorless); if (args.includes("--csv-a")) return dumpCsv(floorless);
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]); if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
@@ -276,8 +392,15 @@ async function main() {
// ---- report ------------------------------------------------------------ // ---- report ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)"); 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(` 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(` 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 preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
const wouldZero = floorless.filter((r) => d(r.postRows) === 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(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` + console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
` (their balance becomes 0 — an assertion, not a migrated figure)`); ` (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( console.log(
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` + `\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`, ` ${"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(` 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(` 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(` 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<folio>\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( console.log(
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` + `\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`, ` ${"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" + "\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" + "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" + "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" + "database. It exists in the DreamHost per-year tables. Section B is no\n" +
"independent defect and does not need a corte to fix.", "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 { } finally {
await prisma.$disconnect(); await prisma.$disconnect();