diff --git a/scripts/corte-audit.mjs b/scripts/corte-audit.mjs new file mode 100644 index 0000000..ebcda08 --- /dev/null +++ b/scripts/corte-audit.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env node +/** + * Corte (year-end cut) audit — READ ONLY. Writes nothing, voids nothing. + * + * Legacy Access ran a corte every year: it moved the year's utility movements + * into a per-year table and stamped one BALANCE FORWARD row per customer, + * dated Jan 1, carrying the closing balance. The platform inherited the ROWS + * (1,170 of them, dated 2026-01-01 — legacy's last cut before the extract) but + * not the PROCESS, and BillingService uses those rows as a per-customer floor + * (BALANCE_FLOOR_JOIN / NOT_SUPERSEDED in apps/api/src/billing/billing.service.ts). + * + * This script reports the two populations that floor does not cover: + * + * A. FLOORLESS customers — no BALANCE FORWARD row at all, so their balance + * 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. + * + * 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. + * + * The folio alone does NOT prove a pair. EFECTIVO folios restart and are + * reused, so `C13483` can collide with an unrelated receipt. Every pair is + * therefore corroborated on money as well: identical amount when both legs + * are in the same currency, or an implied USD->MXN rate inside the band the + * exchange_rates table actually observed that year. Pairs that fail are + * reported separately and must not be counted as duplicated money. + * + * node scripts/corte-audit.mjs # summary + both sections + * node scripts/corte-audit.mjs --cutover 2026-01-01 + * node scripts/corte-audit.mjs --csv-a # per-customer table, section A + * node scripts/corte-audit.mjs --csv-b # per-pair table, section B + * node scripts/corte-audit.mjs --limit 40 # rows printed per section + * + * Needs DATABASE_URL. Point it at PROD — a stale copy answers about itself. + * On a database imported before the BALANCE FORWARD type was minted those rows + * carry typeId NULL instead (see numid.service.ts:80), so the floor is matched + * in BOTH shapes here; matching only the type name reports every customer as + * floorless on such a copy. + */ +import pkg from "../packages/database/generated/client/index.js"; + +const { PrismaClient } = pkg; + +/** Prisma hands raw DECIMAL back as Decimal|string|null; counts as BigInt. */ +const d = (v) => (v == null ? 0 : Number(v)); +const money = (v) => d(v).toFixed(2).padStart(13); +/** + * Raw DATE/DATETIME columns arrive as JS Date objects. String() would render + * them in the host's local zone, which turns a row stored at 2026-01-01 00:00 + * UTC into "Dec 31" on a US Pacific laptop — the ledger is keyed on UTC dates + * everywhere else, so format in UTC and nowhere else. + */ +const day = (v) => (v == null ? "—" : new Date(v).toISOString().slice(0, 10)); + +function arg(args, name, fallback = null) { + const i = args.indexOf(name); + return i === -1 ? fallback : args[i + 1]; +} + +/** + * A row is a balance-forward marker in either of two shapes. Keep in step with + * EMPTY_NUMID_SQL in apps/api/src/customers/numid.service.ts. + */ +const BF_PREDICATE = `( + tt.nameEn = 'BALANCE FORWARD' + OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1 + AND t.legacySourceTable = 'datos2') +)`; + +async function main() { + const args = process.argv.slice(2); + const limit = Number(arg(args, "--limit", "25")); + const prisma = new PrismaClient(); + + try { + // ---- cutover ----------------------------------------------------------- + // Default to the newest balance-forward date actually in the book rather + // than to the current year: the cut the data reflects is a fact, not a + // preference, and hardcoding 2026 would silently lie on any other copy. + const [bfDates] = await prisma.$queryRawUnsafe(` + SELECT MAX(t.transactionDate) AS newest, MIN(t.transactionDate) AS oldest, + COUNT(*) AS rows_, COUNT(DISTINCT t.customerId) AS custs + FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId + WHERE t.voidedAt IS NULL AND ${BF_PREDICATE} + `); + + const cutover = + arg(args, "--cutover") ?? + (bfDates.newest ? new Date(bfDates.newest).toISOString().slice(0, 10) : null); + + if (!cutover) { + console.log("No BALANCE FORWARD rows in this database and no --cutover given."); + return; + } + + console.log(`corte audit — cutover ${cutover}`); + console.log( + ` balance-forward rows: ${d(bfDates.rows_)} across ${d(bfDates.custs)} customers` + + `, dated ${day(bfDates.oldest)}..${day(bfDates.newest)}`, + ); + + // ---- book totals ------------------------------------------------------- + const [book] = 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 + 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, + 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, + 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 >= ? + THEN t.amount ELSE 0 END), 2) AS flooredUsd + FROM transactions t + LEFT JOIN bfloor b ON b.customerId = t.customerId + WHERE t.voidedAt IS NULL AND t.outstanding = 0 + `, + cutover, + cutover, + ); + + // ---- section A: floorless customers ------------------------------------ + const floorless = await prisma.$queryRawUnsafe( + ` + WITH nobf AS ( + SELECT c.id, c.name + 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 n.id, n.name, + COUNT(*) AS rows_, + SUM(t.transactionDate < ?) AS preRows, + SUM(t.transactionDate >= ?) AS postRows, + MIN(t.transactionDate) AS firstTx, + MAX(t.transactionDate) AS lastTx, + SUM(t.domain = 'UTILITY') AS utilRows, + SUM(t.domain = 'INSURANCE') AS insRows, + ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayMxn, + ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 THEN t.amount ELSE 0 END), 2) AS todayUsd, + ROUND(SUM(CASE WHEN t.currency='MXN' AND t.outstanding=0 AND t.transactionDate >= ? + THEN t.amount ELSE 0 END), 2) AS afterMxn, + ROUND(SUM(CASE WHEN t.currency='USD' AND t.outstanding=0 AND t.transactionDate >= ? + THEN t.amount ELSE 0 END), 2) AS afterUsd + FROM nobf n + JOIN transactions t ON t.customerId = n.id AND t.voidedAt IS NULL + GROUP BY n.id, n.name + ORDER BY ABS(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate < ? THEN t.amount ELSE 0 END)) DESC + `, + cutover, cutover, cutover, cutover, cutover, + ); + + // Direction of the pre-cutover history, which is the whole argument for + // flooring rather than carrying it: a corte carries a NET, and a net built + // from receipts whose charges were never migrated is not one. + const [split] = 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 SUM(t.amount < 0) AS charges, ROUND(SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END), 2) AS chargeMxn, + SUM(t.amount > 0) AS credits, ROUND(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 2) AS creditMxn + FROM transactions t JOIN nobf n ON n.id = t.customerId + WHERE t.voidedAt IS NULL AND t.transactionDate < ? + `, + cutover, + ); + + // ---- section B: double-booked receipts --------------------------------- + const pairs = await prisma.$queryRawUnsafe( + ` + SELECT d.id AS datos2Id, e.id AS efectivoId, c.name, + DATE(d.transactionDate) AS datos2Date, DATE(e.transactionDate) AS efectivoDate, + d.amount AS datos2Amount, d.currency AS datos2Currency, + e.amount AS efectivoAmount, e.currency AS efectivoCurrency, + d.reference AS datos2Ref, e.reference AS efectivoRef, + fx.lo AS rateLo, fx.hi AS rateHi + FROM transactions d + JOIN transactions e + ON e.customerId = d.customerId + AND e.legacySourceTable = 'EFECTIVO' + AND e.voidedAt IS NULL + AND e.reference = SUBSTRING(d.reference, 2) + JOIN customers c ON c.id = d.customerId + LEFT JOIN ( + SELECT YEAR(effectiveDate) AS y, MIN(rate) AS lo, MAX(rate) AS hi + FROM exchange_rates GROUP BY YEAR(effectiveDate) + ) fx ON fx.y = YEAR(d.transactionDate) + WHERE d.voidedAt IS NULL + AND d.legacySourceTable = 'datos2' + AND d.reference REGEXP '^C[0-9]+$' + ORDER BY d.transactionDate + `, + ); + + const [unpaired] = await prisma.$queryRawUnsafe( + ` + SELECT COUNT(*) AS n + FROM transactions d + WHERE d.voidedAt IS NULL AND d.legacySourceTable = 'datos2' + AND d.reference REGEXP '^C[0-9]+$' + AND NOT EXISTS ( + SELECT 1 FROM transactions e + WHERE e.customerId = d.customerId AND e.legacySourceTable = 'EFECTIVO' + AND e.voidedAt IS NULL AND e.reference = SUBSTRING(d.reference, 2) + ) + `, + ); + + // ---- CSV escapes ------------------------------------------------------- + if (args.includes("--csv-a")) return dumpCsv(floorless); + if (args.includes("--csv-b")) return dumpCsv(pairs); + + // ---- 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(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`); + + const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0); + const wouldZero = floorless.filter((r) => d(r.postRows) === 0); + const deltaMxn = floorless.reduce((s, r) => s + (d(r.todayMxn) - d(r.afterMxn)), 0); + const deltaUsd = floorless.reduce((s, r) => s + (d(r.todayUsd) - d(r.afterUsd)), 0); + + console.log(`\nA. FLOORLESS CUSTOMERS — ${floorless.length}`); + console.log(` pre-cutover rows they still count: ${preRowsTotal}`); + console.log(` of those rows: ${d(split.charges)} charges (${d(split.chargeMxn).toFixed(2)})` + + ` vs ${d(split.credits)} credits (${d(split.creditMxn).toFixed(2)})`); + 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( + `\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` + + ` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`, + ); + for (const r of floorless.slice(0, limit)) { + console.log( + ` ${(r.name || "(sin nombre)").slice(0, 30).padEnd(30)}` + + ` ${String(d(r.preRows)).padStart(4)} ${String(d(r.postRows)).padStart(4)}` + + ` ${money(r.todayMxn)} ${money(r.afterMxn)} ${day(r.firstTx).padStart(10)}`, + ); + } + if (floorless.length > limit) console.log(` ... ${floorless.length - limit} more (--csv-a)`); + + // A folio match is a hypothesis; the money is the evidence. The band is + // widened by 10% either side of what exchange_rates observed that year, + // because the office keys receipts at its own counter rate, not at a + // published one, and a pair should not be called false over a few centavos. + const classify = (p) => { + const dAmt = d(p.datos2Amount); + const eAmt = d(p.efectivoAmount); + if (p.datos2Currency === p.efectivoCurrency) { + return Math.abs(dAmt - eAmt) < 0.005 ? "confirmed" : "suspect"; + } + if (p.efectivoCurrency !== "USD" || p.datos2Currency !== "MXN") return "suspect"; + if (!eAmt || !p.rateLo) return "suspect"; + const implied = dAmt / eAmt; + return implied >= d(p.rateLo) * 0.9 && implied <= d(p.rateHi) * 1.1 + ? "confirmed" + : "suspect"; + }; + for (const p of pairs) p.verdict = classify(p); + + const confirmed = pairs.filter((p) => p.verdict === "confirmed"); + const suspect = pairs.filter((p) => p.verdict === "suspect"); + const byCust = new Set(confirmed.map((p) => p.name)); + const sameCur = confirmed.filter((p) => p.datos2Currency === p.efectivoCurrency); + const converted = confirmed.filter((p) => p.efectivoCurrency === "USD" && p.datos2Currency === "MXN"); + const efecMxn = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "MXN" ? d(p.efectivoAmount) : 0), 0); + const efecUsd = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "USD" ? d(p.efectivoAmount) : 0), 0); + + console.log(`\nB. DOUBLE-BOOKED RECEIPTS — ${confirmed.length} confirmed pairs across ${byCust.size} customers`); + console.log(` folio matches examined: ${pairs.length} (confirmed ${confirmed.length}, rejected on money ${suspect.length})`); + console.log(` confirmed same-currency, amount equal to the cent: ${sameCur.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(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`); + console.log( + `\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` + + ` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`, + ); + for (const p of confirmed.slice(0, limit)) { + console.log( + ` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` + + ` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` + + ` ${money(p.datos2Amount)} ${p.datos2Currency}` + + ` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}`, + ); + } + if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`); + + if (suspect.length) { + console.log(`\n REJECTED — folio matched, money did not. Not duplicates on this evidence:`); + for (const p of suspect.slice(0, limit)) { + const implied = + d(p.efectivoAmount) && p.datos2Currency !== p.efectivoCurrency + ? ` implied ${(d(p.datos2Amount) / d(p.efectivoAmount)).toFixed(2)}` + : ""; + console.log( + ` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` + + ` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` + + ` ${money(p.datos2Amount)} ${p.datos2Currency}` + + ` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}${implied}`, + ); + } + if (suspect.length > limit) console.log(` ... ${suspect.length - limit} more (--csv-b)`); + } + + console.log( + "\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.", + ); + } finally { + await prisma.$disconnect(); + } +} + +function dumpCsv(rows) { + if (!rows.length) return; + const cols = Object.keys(rows[0]); + console.log(cols.join(",")); + for (const r of rows) { + console.log(cols.map((c) => JSON.stringify(r[c] == null ? "" : String(r[c]))).join(",")); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});