feat(billing): audit the two populations the balance floor misses

Read-only. Reports what a corte would touch without touching it.

The platform inherited legacy's BALANCE FORWARD rows (1,170, all dated
2026-01-01) but not the yearly process that produces them, and
BillingService floors balances per customer on those rows. Two populations
fall outside that floor, and they are unrelated defects that happen to
surface as the same symptom — a customer whose balance reads as a credit
the office does not owe.

A. 102 customers have no BALANCE FORWARD row, so their balance is a raw
   lifetime sum. Only the current-year charge ledger (datos2) was migrated;
   the per-year charge tables stayed in DreamHost. What survives before the
   cutover is the EFECTIVO cash journal, and it shows: 199 of their 201
   pre-cutover rows are credits. Flooring them at 2026-01-01 moves the book
   by -82,297.78 MXN and -52,020.20 USD. 91 of the 102 are left with no rows
   at all, so their balance becomes zero — an assertion, not a figure
   recovered from anywhere, which is why this script proposes and does not
   apply.

B. 276 cash receipts booked twice in 2026 — once in EFECTIVO under folio N,
   once in datos2 as reference CN — across 120 customers, 986,240.00 MXN and
   88,252.00 USD on the EFECTIVO side. Both rows sit after the floor so both
   count. The statement already hides them via
   STATEMENT_EXCLUDED_SOURCE_TABLES; the balances worklist, the movement
   browser and the /clientes/:id card do not.

The folio alone does not establish a pair — folios are reused. Every pair is
corroborated on money too: equal amounts when both legs share a currency, or
an implied USD->MXN rate inside the band exchange_rates observed that year.
Two of 278 folio matches fail that test and are reported apart rather than
counted, both same-day partial applications that need a human.

Matches the balance-forward row in both shapes, as numid.service.ts does.
Databases imported before the type was minted carry those rows with typeId
NULL, and name-only matching reports every customer as floorless on such a
copy — including the dev database, which the API itself currently reads as
+20,653,109.15 MXN against a floored -9,194.61.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:18:48 -07:00
co-authored by Claude Opus 5
parent 9929a9a3ac
commit 75dcbc11b8
+355
View File
@@ -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);
});