diff --git a/scripts/corte-audit.mjs b/scripts/corte-audit.mjs index ebcda08..7933d98 100644 --- a/scripts/corte-audit.mjs +++ b/scripts/corte-audit.mjs @@ -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)`);