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:
+60
-10
@@ -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)`);
|
||||||
|
|||||||
Reference in New Issue
Block a user