fix(billing): folio typos hid two double-booked receipts from the audit

Matching the datos2 `CN` reference against EFECTIVO folio `N` finds pairs
only where both were keyed correctly. Jorge Cuadros Jr's account carries
`C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 MXN, one
receipt — and POWERS carries `C135808` against `13508`. Folio matching alone
calls both accounts clean, which is exactly backwards: an account used as a
validator reporting a false negative is worse than no audit.

A second pass now sweeps the C-refs the first pass left orphaned, on
proximity alone (same customer, same three-day window), and both passes are
judged by the same money rules. The folio is demoted to a lead: it can be
wrong in either direction, so it never decides anything on its own.

278 confirmed pairs, up from 276 — 989,740.00 MXN and 88,392.00 USD on the
EFECTIVO side. Of the 97 orphaned C-refs only 3 had any EFECTIVO row nearby,
so the remaining 94 are datos2-only postings rather than misses.

Rejections rise to 3. RAMIREZ, SUSANA pairs 3,320.00 MXN against 18,000.00
the next day; that is a partial application, not a duplicate, and it needs a
human rather than a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 23:35:53 -07:00
co-authored by Claude Opus 5
parent 75dcbc11b8
commit 9973488330
+60 -10
View File
@@ -24,12 +24,21 @@
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances * them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
* worklist, the movement browser and the /clientes/:id card do not. * worklist, the movement browser and the /clientes/:id card do not.
* *
* The folio alone does NOT prove a pair. EFECTIVO folios restart and are * The folio alone neither proves nor disproves a pair, so it is used as a
* reused, so `C13483` can collide with an unrelated receipt. Every pair is * lead and never as the verdict. Folios are reused, so `C13483` can collide
* therefore corroborated on money as well: identical amount when both legs * with an unrelated receipt; folios are also mistyped, so a genuine pair can
* are in the same currency, or an implied USD->MXN rate inside the band the * carry two different numbers. Detection therefore runs twice — once on the
* exchange_rates table actually observed that year. Pairs that fail are * `CN` cross-reference, once over the C-refs that pass left orphaned, this
* reported separately and must not be counted as duplicated money. * time on proximity alone (same customer, within three days) — and BOTH
* passes are then judged on the money: identical amount when the two legs
* share a currency, or an implied USD->MXN rate inside the band the
* exchange_rates table actually observed that year. Anything that fails is
* reported apart and must not be counted as duplicated money.
*
* The second pass is not a refinement. Jorge Jr's own account carries
* `C13647` against EFECTIVO folio `13649` — same day, same 3,500.00 — and
* POWERS carries `C135808` against `13508`. Folio matching alone reports
* both accounts as clean.
* *
* node scripts/corte-audit.mjs # summary + both sections * node scripts/corte-audit.mjs # summary + both sections
* node scripts/corte-audit.mjs --cutover 2026-01-01 * node scripts/corte-audit.mjs --cutover 2026-01-01
@@ -215,6 +224,37 @@ async function main() {
`, `,
); );
// Pass two — the leads pass one could not follow. Same shape of row, judged
// by the same money rules below, so a folio typo costs nothing.
const nearby = 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.amount > 0
AND ABS(DATEDIFF(e.transactionDate, d.transactionDate)) <= 3
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]+$'
AND NOT EXISTS (
SELECT 1 FROM transactions x
WHERE x.customerId = d.customerId AND x.legacySourceTable = 'EFECTIVO'
AND x.voidedAt IS NULL AND x.reference = SUBSTRING(d.reference, 2)
)
ORDER BY d.transactionDate
`,
);
const [unpaired] = await prisma.$queryRawUnsafe( const [unpaired] = await prisma.$queryRawUnsafe(
` `
SELECT COUNT(*) AS n SELECT COUNT(*) AS n
@@ -231,7 +271,7 @@ async function main() {
// ---- 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); if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
// ---- report ------------------------------------------------------------ // ---- report ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)"); console.log("\nBOOK (voided and outstanding rows excluded)");
@@ -281,7 +321,15 @@ async function main() {
? "confirmed" ? "confirmed"
: "suspect"; : "suspect";
}; };
for (const p of pairs) p.verdict = classify(p); for (const p of pairs) {
p.pass = "folio";
p.verdict = classify(p);
}
for (const p of nearby) {
p.pass = "proximity";
p.verdict = classify(p);
}
pairs.push(...nearby);
const confirmed = pairs.filter((p) => p.verdict === "confirmed"); const confirmed = pairs.filter((p) => p.verdict === "confirmed");
const suspect = pairs.filter((p) => p.verdict === "suspect"); const suspect = pairs.filter((p) => p.verdict === "suspect");
@@ -292,7 +340,9 @@ async function main() {
const efecUsd = confirmed.reduce((s, p) => s + (p.efectivoCurrency === "USD" ? 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(`\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})`); const byFolio = confirmed.filter((p) => p.pass === "folio").length;
console.log(` candidates examined: ${pairs.length} (confirmed ${confirmed.length}, rejected on money ${suspect.length})`);
console.log(` found by folio cross-reference: ${byFolio}, by proximity after a folio miss: ${confirmed.length - byFolio}`);
console.log(` confirmed same-currency, amount equal to the cent: ${sameCur.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(` 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)}`);
@@ -306,7 +356,7 @@ async function main() {
` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` + ` ${(p.name || "(sin nombre)").slice(0, 28).padEnd(28)}` +
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` + ` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
` ${money(p.datos2Amount)} ${p.datos2Currency}` + ` ${money(p.datos2Amount)} ${p.datos2Currency}` +
` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}`, ` ${money(p.efectivoAmount)} ${p.efectivoCurrency} ${p.datos2Ref}/${p.efectivoRef}`,
); );
} }
if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`); if (confirmed.length > limit) console.log(` ... ${confirmed.length - limit} more (--csv-b)`);