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
* 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.
* 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
* with an unrelated receipt; folios are also mistyped, so a genuine pair can
* carry two different numbers. Detection therefore runs twice — once on the
* `CN` cross-reference, once over the C-refs that pass left orphaned, this
* 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 --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(
`
SELECT COUNT(*) AS n
@@ -231,7 +271,7 @@ async function main() {
// ---- CSV escapes -------------------------------------------------------
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 ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)");
@@ -281,7 +321,15 @@ async function main() {
? "confirmed"
: "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 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);
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 USD receipt posted to datos2 in MXN: ${converted.length}`);
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)}` +
` ${day(p.datos2Date).padStart(10)} ${day(p.efectivoDate).padStart(10)}` +
` ${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)`);