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>
406 lines
20 KiB
JavaScript
406 lines
20 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
|
|
* the EFECTIVO cash journal — receipts with no matching charges — so
|
|
* those sums read as the office owing money it does not owe.
|
|
*
|
|
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in
|
|
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both
|
|
* rows are after the 2026-01-01 floor, so both count. The statement hides
|
|
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances
|
|
* worklist, the movement browser and the /clientes/:id card do not.
|
|
*
|
|
* 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')
|
|
)`;
|
|
|
|
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 todayMxn,
|
|
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 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,
|
|
);
|
|
|
|
// ---- 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)
|
|
)
|
|
`,
|
|
);
|
|
|
|
// ---- 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(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
|
|
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
|
|
|
|
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(
|
|
`\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(
|
|
`\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 an\n" +
|
|
"independent defect and does not need a corte to fix.",
|
|
);
|
|
} 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);
|
|
});
|