#!/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);
});
