The script carried its own copy of the balance rules, and since 1.0.26 that copy was stale: it reported 631,078.98 MXN where the application reports -416,403.42. An audit that disagrees with production is worse than no audit, because its numbers look authoritative and diff cleanly against yesterday's run. It now mirrors NOT_CASH_JOURNAL and archiveIsHistorySql, keeps the floor-only figure as a labelled line so older runs still diff, and says plainly which line is the app's. Section B stops being an open defect list. EFECTIVO is a receipt book whose receipts are posted to the datos2 ledger by design, so those rows exist and always will; what matters is whether any of them still reaches a balance. That is now a counted assertion which must stay at 0, and it fails loudly if someone writes a balance query that forgets the exclusion. Section A grew the line that changes its recommendation. "Floor them, never carry" was written when the floorless group looked like utilities receipts whose charges were never migrated. Measured on production today: of the 99, ninety-five carry insurance-line cash and NONE carry utilities rows. The seguros EFECTIVO is that line's only ledger, so flooring them deletes receipts rather than removing a double count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
551 lines
28 KiB
JavaScript
551 lines
28 KiB
JavaScript
#!/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
|
|
* a cash journal — receipts with no matching charges — so those sums read
|
|
* as the office owing money it does not owe.
|
|
*
|
|
* READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
|
|
* import the group is mostly insurance-only customers whose rows come from
|
|
* the seguros database's own EFECTIVO, which is that line's ONLY ledger.
|
|
* Flooring those deletes receipts instead of removing a double count. The
|
|
* "floor them, never carry" argument holds for the utilities rows alone.
|
|
*
|
|
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in
|
|
* EFECTIVO with folio `N` and once in datos2 with reference `CN`.
|
|
*
|
|
* This is NOT an office data-entry defect, which is what it looked like
|
|
* while the pair count kept growing at ~40/month. EFECTIVO is the paper
|
|
* receipt book and every receipt in it is POSTED to the datos2 ledger by
|
|
* design — verified against the live legacy database, 296 of the 297
|
|
* receipts written in 2026 carry a matching posting. Legacy summed the
|
|
* ledger alone. The duplication was the migration flattening a journal and
|
|
* its postings into one table, and since 1.0.26 the application drops the
|
|
* journal from every balance. Section B now reports what the journal holds
|
|
* and asserts that none of it still reaches a balance.
|
|
*
|
|
* 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
|
|
* 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')
|
|
)`;
|
|
|
|
/**
|
|
* The rest of what a balance query drops, over and above the floor. Mirrors
|
|
* NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit
|
|
* that computes a different book than the application is worse than no audit,
|
|
* because its numbers look authoritative and diff cleanly against yesterday's.
|
|
*
|
|
* The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table
|
|
* called EFECTIVO and that one is the insurance line's only ledger; matching on
|
|
* the table name alone would report 55,444.95 USD of real receivables as
|
|
* duplicate cash. See efectivo-is-a-journal-not-a-ledger.
|
|
*/
|
|
const NOT_CASH_JOURNAL = `(
|
|
t.legacySourceDb IS NULL
|
|
OR t.legacySourceDb <> 'UTILITIES'
|
|
OR t.legacySourceTable IS NULL
|
|
OR t.legacySourceTable NOT IN
|
|
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
|
)`;
|
|
|
|
/**
|
|
* An imported period counts as history below the year start and is dropped at
|
|
* or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL
|
|
* for an app-captured row, which would silently drop every one.
|
|
*
|
|
* The bound is the running calendar year, matching currentYearStart() in the
|
|
* application rather than the cutover — the app's current period is "this
|
|
* year", whatever cut the data happens to reflect.
|
|
*/
|
|
const yearStart = `${new Date().getUTCFullYear()}-01-01`;
|
|
const ARCHIVE_IS_HISTORY = `(
|
|
t.legacySourceTable IS NULL
|
|
OR t.legacySourceTable NOT LIKE 'datos2@%'
|
|
OR t.transactionDate < '${yearStart}'
|
|
)`;
|
|
|
|
/** Everything a balance drops apart from the floor itself. */
|
|
const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`;
|
|
|
|
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 floorOnlyMxn,
|
|
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 floorOnlyUsd,
|
|
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
|
AND ${READ_SCOPE} 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)
|
|
AND ${READ_SCOPE} 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,
|
|
);
|
|
|
|
// What the floorless population's balance is actually MADE OF.
|
|
//
|
|
// "Floor them, never carry" was written when this group looked like
|
|
// utilities cash receipts whose charges were never migrated. It is not that
|
|
// any more. After the prior-period import the group is 99 customers, and
|
|
// almost all of them are insurance-only — their rows come from the seguros
|
|
// database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts
|
|
// it a second time, so flooring it does not remove a double count, it
|
|
// deletes receipts. Split the two so the remedy is chosen per population
|
|
// rather than for the group.
|
|
const [floorlessMix] = 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
|
|
COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts,
|
|
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN'
|
|
THEN t.amount ELSE 0 END), 2) AS insMxn,
|
|
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD'
|
|
THEN t.amount ELSE 0 END), 2) AS insUsd,
|
|
COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts
|
|
FROM transactions t JOIN nobf n ON n.id = t.customerId
|
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 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
|
|
`,
|
|
);
|
|
|
|
// 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
|
|
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)
|
|
)
|
|
`,
|
|
);
|
|
|
|
// REGRESSION GUARD. Since 1.0.26 the application drops the whole cash
|
|
// journal from every balance, so none of section B's rows should reach one
|
|
// any more. This counts the ones that still do: it is 0 while the exclusion
|
|
// holds, and goes non-zero the moment someone reintroduces a balance query
|
|
// that forgets it. A defect list that cannot tell you whether the defect is
|
|
// still live is just history.
|
|
const [stillCounted] = 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 COUNT(*) AS n,
|
|
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn,
|
|
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd
|
|
FROM transactions t
|
|
LEFT JOIN bfloor b ON b.customerId = t.customerId
|
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0
|
|
AND t.legacySourceDb = 'UTILITIES'
|
|
AND t.legacySourceTable IN
|
|
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
|
|
AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
|
|
AND ${READ_SCOPE}
|
|
`,
|
|
);
|
|
|
|
// ---- CSV escapes -------------------------------------------------------
|
|
if (args.includes("--csv-a")) return dumpCsv(floorless);
|
|
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
|
|
|
|
// ---- 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(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`);
|
|
console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
|
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
|
|
console.log(
|
|
" (the middle line is the floor alone, kept only so older runs of this\n" +
|
|
" script still diff against something. The app has dropped the cash\n" +
|
|
" journal and windowed the archives since 1.0.26; USD going to zero on\n" +
|
|
" the utilities side is correct, that ledger is peso-denominated.)",
|
|
);
|
|
|
|
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(
|
|
` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` +
|
|
` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` +
|
|
`, ${d(floorlessMix.utilCusts)} carry utilities rows`,
|
|
);
|
|
console.log(
|
|
` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` +
|
|
` line's only ledger — nothing posts it twice — so flooring those\n` +
|
|
` customers deletes receipts rather than removing a double count.\n` +
|
|
` The argument for flooring holds for the utilities rows alone.`,
|
|
);
|
|
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.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");
|
|
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`);
|
|
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)}`);
|
|
console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
|
|
console.log(
|
|
` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` +
|
|
` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` +
|
|
`${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`,
|
|
);
|
|
console.log(
|
|
` The rows below still exist and always will; the ledger's own C<folio>\n` +
|
|
` posting is the copy that counts. This section is now a record of what\n` +
|
|
` the journal holds, not a list of money being double-counted.`,
|
|
);
|
|
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}/${p.efectivoRef}`,
|
|
);
|
|
}
|
|
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 no\n" +
|
|
"longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" +
|
|
"that matters there is the regression count, which must stay at 0.",
|
|
);
|
|
} 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);
|
|
});
|