// Integration tests for the bank statement import + matching + reconcile API
// (Task 3 of docs/superpowers/plans/2026-08-01-bank-rec.md; spec:
// docs/superpowers/specs/2026-08-01-bank-rec-design.md). Same patterns as
// tests/server/setup.test.ts (multipart uploads, hand-built boundary bodies)
// and tests/server/ap-payments.test.ts (createTestDb, app.inject, the seeded
// chart of accounts, trial-balance cross-checks).

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db.js";
// Where the database is, resolved once for the whole run: your ADMIN_URL /
// DATABASE_URL if you set one, otherwise the PostgreSQL Balanced Sum installed on
// this machine. Every file in this directory used to carry its own copy of that
// decision, hardcoded to the Docker container that no longer exists.
import { dbUrl } from "../helpers/test-db-admin.js";
import { buildApp } from "../../server/src/app.js";
import type { FastifyInstance } from "fastify";

async function buildTestApp(dbName: string): Promise<FastifyInstance> {
  return buildApp({
    databaseUrl: await dbUrl(dbName, "app", "app"),
    adminDatabaseUrl: await dbUrl(dbName),
  });
}

function post(app: FastifyInstance, companyId: string, url: string, payload?: unknown) {
  return app.inject({
    method: "POST", url, headers: { "x-company-id": companyId }, payload: payload ?? {},
  });
}
function get(app: FastifyInstance, companyId: string, url: string) {
  return app.inject({ method: "GET", url, headers: { "x-company-id": companyId } });
}

function multipartPayload(
  fileBuffer: Buffer, filename: string, fields: Record<string, string> = {},
): { payload: Buffer; contentType: string } {
  const boundary = `----bankTestBoundary${Math.random().toString(16).slice(2)}`;
  const parts: Buffer[] = [];
  for (const [key, value] of Object.entries(fields)) {
    parts.push(Buffer.from(
      `--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${value}\r\n`,
    ));
  }
  parts.push(Buffer.from(
    `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: text/plain\r\n\r\n`,
  ));
  parts.push(fileBuffer);
  parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
  return { payload: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` };
}

function postMultipart(
  app: FastifyInstance, companyId: string, url: string,
  fileBuffer: Buffer, filename: string, fields: Record<string, string> = {},
) {
  const { payload, contentType } = multipartPayload(fileBuffer, filename, fields);
  return app.inject({
    method: "POST", url,
    headers: { "x-company-id": companyId, "content-type": contentType },
    payload,
  });
}

let db: TestDb;
let app: FastifyInstance;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildTestApp(db.dbName);
}, 60_000);

afterAll(async () => {
  await app.close();
  await db.close();
});

// tests/helpers/db.ts's seeded chart (both companies get an identical copy):
// 1000 Cash (asset), 1100 Accounts Receivable (asset), 1200 Due from Affiliate
// (asset), 2000 Accounts Payable (liability), 2200 Due to Affiliate
// (liability), 3000 Opening Equity (equity), 4000 Revenue (income),
// 6000 Operating Expense (expense).

const CASH_A = () => String(db.accounts["1000"]!.A);
const REV_A = () => String(db.accounts["4000"]!.A);
const EXP_A = () => String(db.accounts["6000"]!.A);

async function tbBalance(companyId: string, code: string): Promise<string> {
  const res = await get(app, companyId, "/api/c/reports/trial-balance");
  return res.json().rows.find((r: { code: string }) => r.code === code)?.balance ?? "0.0000";
}

function toMicros(amount: string): bigint {
  const negative = amount.startsWith("-");
  const unsigned = negative ? amount.slice(1) : amount;
  const [whole, frac = ""] = unsigned.split(".");
  const micros = BigInt(whole || "0") * 10000n + BigInt(frac.padEnd(4, "0").slice(0, 4));
  return negative ? -micros : micros;
}

function addMoney(a: string, b: string): string {
  const total = toMicros(a) + toMicros(b);
  const neg = total < 0n;
  const abs = neg ? -total : total;
  return `${neg ? "-" : ""}${abs / 10000n}.${(abs % 10000n).toString().padStart(4, "0")}`;
}

/** Records + posts a money-in (or money-out, for a negative amount) entry
 *  directly against Cash via POST /api/c/entries — used to seed the ledger
 *  side of a match. Returns the created entry id + entry number. */
async function createLedgerEntry(
  companyId: string, date: string, signedAmount: string, categoryAccountId: string, memo = "test entry",
): Promise<{ entryId: string; entryNo: string }> {
  const negative = signedAmount.startsWith("-");
  const amount = negative ? signedAmount.slice(1) : signedAmount;
  const res = negative
    ? await post(app, companyId, "/api/c/entries", {
        kind: "money-out", date, amount, fromAccountId: CASH_A(), categoryAccountId, memo,
      })
    : await post(app, companyId, "/api/c/entries", {
        kind: "money-in", date, amount, intoAccountId: CASH_A(), categoryAccountId, memo,
      });
  expect(res.statusCode).toBe(201);
  const body = res.json();
  return { entryId: body.entryId, entryNo: body.entryNo };
}

// ---------------------------------------------------------------------------
// fixture builders
// ---------------------------------------------------------------------------

interface OfxTxnSpec { fitid?: string; date: string; amount: string; name: string; memo?: string }

/** OFX 1.x SGML — colon-delimited header, aggregates closed but leaves not —
 *  the shape banks actually emit (mirrors tests/tools/ofx.test.ts's fixture). */
function buildOfx(
  txns: OfxTxnSpec[],
  opts: { dtStart?: string; dtEnd?: string; ledgerBal?: string; dtAsOf?: string; acctId?: string } = {},
): string {
  const lines = txns.map((t) => {
    let s = `<STMTTRN>\n<TRNTYPE>DEBIT\n<DTPOSTED>${t.date.replace(/-/g, "")}\n<TRNAMT>${t.amount}\n`;
    if (t.fitid) s += `<FITID>${t.fitid}\n`;
    s += `<NAME>${t.name}\n`;
    if (t.memo) s += `<MEMO>${t.memo}\n`;
    s += `</STMTTRN>`;
    return s;
  }).join("\n");

  const ledger = opts.ledgerBal !== undefined
    ? `<LEDGERBAL>\n<BALAMT>${opts.ledgerBal}\n<DTASOF>${(opts.dtAsOf ?? opts.dtEnd ?? "20260131").replace(/-/g, "")}\n</LEDGERBAL>\n`
    : "";

  return `OFXHEADER:100
DATA:OFXSGML
VERSION:102
SECURITY:NONE
ENCODING:USASCII

<OFX>
<BANKMSGSRSV1>
<STMTTRNRS>
<STMTRS>
<CURDEF>USD
<BANKACCTFROM>
<ACCTID>${opts.acctId ?? "1234567890"}
</BANKACCTFROM>
<BANKTRANLIST>
<DTSTART>${(opts.dtStart ?? "20260101").replace(/-/g, "")}
<DTEND>${(opts.dtEnd ?? "20260131").replace(/-/g, "")}
${lines}
</BANKTRANLIST>
${ledger}</STMTRS>
</STMTTRNRS>
</BANKMSGSRSV1>
</OFX>
`;
}

interface CsvRowSpec { date: string; description: string; amount: string }

function buildCsv(rows: CsvRowSpec[]): string {
  const header = "Date,Description,Amount\n";
  const body = rows.map((r) => `${r.date},${r.description},${r.amount}`).join("\n");
  return header + body + "\n";
}

const CSV_MAPPING_FIELDS = {
  headerRows: "1", dateCol: "0", descriptionCol: "1", amountMode: "signed", amountCol: "2", dateFormat: "MDY",
};

// ---------------------------------------------------------------------------
// CSV import: first-time mapping (via preview) + saved-map reuse
// ---------------------------------------------------------------------------

describe("CSV import — preview, first-time mapping, saved-map reuse", () => {
  it("previews a CSV's header/sample rows and guesses, without importing anything", async () => {
    const csv = buildCsv([{ date: "01/05/2026", description: "Coffee Shop", amount: "-45.67" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/csv-preview", Buffer.from(csv), "stmt.csv");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.headerRow).toEqual(["Date", "Description", "Amount"]);
    expect(body.sampleRows).toEqual([["01/05/2026", "Coffee Shop", "-45.67"]]);
    expect(body.dateCol).toBe(0);
    expect(body.amountCol).toBe(2);

    const stmtCount = await db.admin.query("SELECT count(*)::int AS n FROM bank_statements WHERE company_id = $1", [db.companyA]);
    expect(stmtCount.rows[0].n).toBe(0); // preview never imports
  });

  it("422s when no mapping fields are supplied for a first-time CSV import", async () => {
    const csv = buildCsv([{ date: "01/05/2026", description: "Coffee Shop", amount: "-45.67" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "no-mapping.csv", {
      accountId: CASH_A(),
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/no saved column mapping/i);
  });

  it("imports with submitted mapping fields, saves the mapping, then reuses it on the next import", async () => {
    const csv1 = buildCsv([
      { date: "02/01/2026", description: "Coffee Shop", amount: "-12.50" },
      { date: "02/02/2026", description: "Client Payment", amount: "500.00" },
    ]);
    const res1 = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv1), "first.csv", {
      accountId: CASH_A(), ...CSV_MAPPING_FIELDS,
    });
    expect(res1.statusCode).toBe(201);
    const body1 = res1.json();
    expect(body1.lines).toEqual({ imported: 2, skippedDuplicates: 0 });
    expect(body1.matches.suggested + body1.matches.unmatched).toBe(2);
    expect(body1.prefills).toEqual({}); // CSV never carries an ending balance/period

    const mapRow = await db.admin.query(
      "SELECT mapping FROM bank_csv_maps WHERE company_id = $1 AND money_account_id = $2",
      [db.companyA, CASH_A()],
    );
    expect(mapRow.rows[0].mapping).toMatchObject({ dateCol: 0, descriptionCol: 1, amountCol: 2, amountMode: "signed" });

    // Second import, SAME account, no mapping fields at all — the saved map is used.
    const csv2 = buildCsv([{ date: "02/03/2026", description: "Office Depot", amount: "-88.10" }]);
    const res2 = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv2), "second.csv", {
      accountId: CASH_A(),
    });
    expect(res2.statusCode).toBe(201);
    expect(res2.json().lines).toEqual({ imported: 1, skippedDuplicates: 0 });
  });
});

// ---------------------------------------------------------------------------
// CSV column mapping — GET/POST .../accounts/:id/csv-map (the recovery path
// for a wrong first mapping)
// ---------------------------------------------------------------------------

describe("GET/POST /api/c/bank/accounts/:id/csv-map", () => {
  it("404s with the English message before anything is saved, then round-trips a saved mapping", async () => {
    const { companyId, moneyAccountId } = await createIsolatedCompany("CSV Map Roundtrip Co");

    const before = await get(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`);
    expect(before.statusCode).toBe(404);
    expect(before.json().error).toBe("No saved column layout for this account yet.");

    const mappingA = {
      columnCount: 3, headerRows: 1, dateCol: 0, descriptionCol: 1, amountCol: 2,
      amountMode: "signed", dateFormat: "MDY",
    };
    const setRes = await post(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`, mappingA);
    expect(setRes.statusCode).toBe(200);
    expect(setRes.json().mapping).toMatchObject({ dateCol: 0, descriptionCol: 1, amountCol: 2, amountMode: "signed" });

    const getRes = await get(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`);
    expect(getRes.statusCode).toBe(200);
    expect(getRes.json().mapping).toMatchObject({ dateCol: 0, descriptionCol: 1, amountCol: 2, amountMode: "signed" });
  });

  it("400s an invalid mapping (out-of-range column) with English, nothing saved", async () => {
    const { companyId, moneyAccountId } = await createIsolatedCompany("CSV Map Invalid Co");
    const res = await post(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`, {
      columnCount: 3, headerRows: 1, dateCol: 5, descriptionCol: 1, amountCol: 2,
      amountMode: "signed", dateFormat: "MDY",
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/out of range/i);

    const stillMissing = await get(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`);
    expect(stillMissing.statusCode).toBe(404);
  });

  it("re-mapping changes which columns the NEXT import reads — a fixture that only parses under the new layout", async () => {
    const { companyId, moneyAccountId } = await createIsolatedCompany("CSV Remap Co");

    const mappingA = {
      columnCount: 3, headerRows: 1, dateCol: 0, descriptionCol: 1, amountCol: 2,
      amountMode: "signed", dateFormat: "MDY",
    };
    const setA = await post(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`, mappingA);
    expect(setA.statusCode).toBe(200);

    // Shaped for mapping A: Date, Description, Amount.
    const csvA = "Date,Description,Amount\n03/15/2027,Old Mapping Row,-20.00\n";
    const importA = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csvA), "map-a.csv", {
      accountId: moneyAccountId,
    });
    expect(importA.statusCode).toBe(201);
    const lineA = (await get(app, companyId, `/api/c/bank/statements/${importA.json().statementId}/lines`)).json().lines[0];
    expect(lineA).toMatchObject({ postedDate: "2027-03-15", description: "Old Mapping Row", amount: "-20.0000" });

    // Shaped for the FUTURE mapping B: Amount, Description, Date — under the
    // STILL-ACTIVE mapping A, column 0 ("-30.00") would be read as a date and
    // fail to parse, so this file has no legally importable lines yet.
    const csvB = "Amount,Description,Date\n-30.00,New Mapping Row,03/20/2027\n";
    const stillOldMapping = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csvB), "map-b-too-early.csv", {
      accountId: moneyAccountId,
    });
    expect(stillOldMapping.statusCode).toBe(422);
    expect(stillOldMapping.json().error).toMatch(/no transactions were found/i);

    // Re-map (the recovery path under test): amount now column 0, date now column 2.
    const mappingB = {
      columnCount: 3, headerRows: 1, dateCol: 2, descriptionCol: 1, amountCol: 0,
      amountMode: "signed", dateFormat: "MDY",
    };
    const setB = await post(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/csv-map`, mappingB);
    expect(setB.statusCode).toBe(200);

    // The SAME file that just failed now imports correctly under the new
    // saved mapping, with no mapping fields resubmitted.
    const importB = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csvB), "map-b.csv", {
      accountId: moneyAccountId,
    });
    expect(importB.statusCode).toBe(201);
    const lineB = (await get(app, companyId, `/api/c/bank/statements/${importB.json().statementId}/lines`)).json().lines[0];
    expect(lineB).toMatchObject({ postedDate: "2027-03-20", description: "New Mapping Row", amount: "-30.0000" });
  });
});

// ---------------------------------------------------------------------------
// OFX import, incl. prefills
// ---------------------------------------------------------------------------

describe("OFX import", () => {
  it("imports OFX transactions and prefills ending balance + period end from LEDGERBAL/DTEND", async () => {
    const ofx = buildOfx(
      [
        { fitid: "OFX-1", date: "2026-03-05", amount: "-45.67", name: "Coffee Shop" },
        { fitid: "OFX-2", date: "2026-03-10", amount: "1250.00", name: "Payroll Deposit" },
      ],
      { dtStart: "2026-03-01", dtEnd: "2026-03-31", ledgerBal: "3204.33", dtAsOf: "2026-03-31" },
    );
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "march.ofx", {
      accountId: CASH_A(),
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.lines).toEqual({ imported: 2, skippedDuplicates: 0 });
    expect(body.prefills).toEqual({ endingBalance: "3204.33", periodEnd: "2026-03-31" });

    const stmtRow = await db.admin.query(
      "SELECT period_start::text, period_end::text, ending_balance, format FROM bank_statements WHERE id = $1",
      [body.statementId],
    );
    expect(stmtRow.rows[0]).toMatchObject({
      period_start: "2026-03-01", period_end: "2026-03-31", ending_balance: "3204.3300", format: "ofx",
    });
  });

  it("422s a garbage OFX file with the parser's own loud English, nothing written", async () => {
    const res = await postMultipart(
      app, db.companyA, "/api/c/bank/statements", Buffer.from("<OFX>not really valid</OFX>"), "bad.ofx",
      { accountId: CASH_A() },
    );
    expect(res.statusCode).toBe(422);
  });
});

// ---------------------------------------------------------------------------
// multipart upload edges
// ---------------------------------------------------------------------------

describe("POST /api/c/bank/statements — multipart edges", () => {
  it("400s when the request has no file part at all", async () => {
    // multipartPayload always appends a "file" part; build the body by hand
    // here, with only the accountId field and no file part whatsoever.
    const boundary = "----noFileBoundary";
    const payload = Buffer.from(
      `--${boundary}\r\nContent-Disposition: form-data; name="accountId"\r\n\r\n${CASH_A()}\r\n--${boundary}--\r\n`,
    );
    const res = await app.inject({
      method: "POST", url: "/api/c/bank/statements",
      headers: { "x-company-id": db.companyA, "content-type": `multipart/form-data; boundary=${boundary}` },
      payload,
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/choose a file/i);
  });

  it("400s when accountId is missing from the request", async () => {
    const csv = buildCsv([{ date: "12/10/2026", description: "No Account", amount: "-1.00" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "no-account.csv", {
      ...CSV_MAPPING_FIELDS,
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/pick a money account/i);
  });

  it("422s on structurally garbage CSV content (an unterminated quoted field), nothing written", async () => {
    const garbage = 'date,description,amount\n"unterminated,x,1.00\n';
    const before = await db.admin.query("SELECT count(*)::int AS n FROM bank_statements WHERE company_id = $1", [db.companyA]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(garbage), "garbage.csv", {
      accountId: CASH_A(),
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/quote mark opened/i);
    const after = await db.admin.query("SELECT count(*)::int AS n FROM bank_statements WHERE company_id = $1", [db.companyA]);
    expect(after.rows[0].n).toBe(before.rows[0].n);
  });

  it("400s an unknown/unrecognized money account id", async () => {
    const csv = buildCsv([{ date: "12/11/2026", description: "Bad Account", amount: "-1.00" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "bad-account.csv", {
      accountId: "999999999", ...CSV_MAPPING_FIELDS,
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/pick a money account/i);
  });
});

// ---------------------------------------------------------------------------
// dedupe: FITID (OFX) and heuristic (CSV) re-import
// ---------------------------------------------------------------------------

describe("dedupe on re-import", () => {
  it("OFX: re-importing the identical file is rejected outright (sha unique)", async () => {
    const ofx = buildOfx([{ fitid: "DUP-1", date: "2026-04-01", amount: "-10.00", name: "Dup Test" }]);
    const first = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "dup.ofx", { accountId: CASH_A() });
    expect(first.statusCode).toBe(201);

    const second = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "dup.ofx", { accountId: CASH_A() });
    expect(second.statusCode).toBe(409);
    expect(second.json().error).toMatch(/already been imported/i);
  });

  it("OFX: overlapping statement periods skip already-seen FITIDs, importing only the new ones", async () => {
    const ofxA = buildOfx([
      { fitid: "OVL-1", date: "2026-05-01", amount: "-20.00", name: "A" },
      { fitid: "OVL-2", date: "2026-05-02", amount: "-30.00", name: "B" },
    ], { dtStart: "2026-05-01", dtEnd: "2026-05-15" });
    const resA = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofxA), "overlap-a.ofx", { accountId: CASH_A() });
    expect(resA.statusCode).toBe(201);
    expect(resA.json().lines).toEqual({ imported: 2, skippedDuplicates: 0 });

    const ofxB = buildOfx([
      { fitid: "OVL-2", date: "2026-05-02", amount: "-30.00", name: "B" }, // same FITID, overlapping period
      { fitid: "OVL-3", date: "2026-05-16", amount: "-40.00", name: "C" },
    ], { dtStart: "2026-05-02", dtEnd: "2026-05-31" });
    const resB = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofxB), "overlap-b.ofx", { accountId: CASH_A() });
    expect(resB.statusCode).toBe(201);
    expect(resB.json().lines).toEqual({ imported: 1, skippedDuplicates: 1 });
  });

  it("CSV: heuristic dedupe skips (date, amount, normalized description) matches against existing lines, but keeps within-file duplicates distinct", async () => {
    const csvA = buildCsv([{ date: "06/01/2026", description: "  Rent   Payment ", amount: "-1000.00" }]);
    const resA = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csvA), "csv-dedupe-a.csv", {
      accountId: CASH_A(), ...CSV_MAPPING_FIELDS,
    });
    expect(resA.statusCode).toBe(201);
    expect(resA.json().lines).toEqual({ imported: 1, skippedDuplicates: 0 });

    // Same (date, amount, normalized description) re-appears in a second
    // file (normalization differs only in whitespace/case) plus one genuinely
    // new line, plus a THIRD line identical to the new one (within-file
    // duplicate — must NOT be deduped against its sibling).
    const csvB = buildCsv([
      { date: "06/01/2026", description: "rent payment", amount: "-1000.00" }, // dup of resA's line
      { date: "06/02/2026", description: "Coffee", amount: "-5.00" },
      { date: "06/02/2026", description: "Coffee", amount: "-5.00" }, // distinct: same-day identical charge
    ]);
    const resB = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csvB), "csv-dedupe-b.csv", {
      accountId: CASH_A(),
    });
    expect(resB.statusCode).toBe(201);
    expect(resB.json().lines).toEqual({ imported: 2, skippedDuplicates: 1 });
  });

  it("CSV: multiplicity-aware dedupe — a genuinely new transaction that collides with an already-imported one still imports (regression: a plain membership check dropped BOTH)", async () => {
    // Exactly one $5 coffee already on file for this account/day.
    const csvA = buildCsv([{ date: "06/10/2026", description: "Coffee", amount: "-5.00" }]);
    const resA = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csvA), "mult-a.csv", {
      accountId: CASH_A(),
    });
    expect(resA.statusCode).toBe(201);
    expect(resA.json().lines).toEqual({ imported: 1, skippedDuplicates: 0 });

    // An overlapping re-export contains TWO lines with the identical
    // heuristic key: one is the SAME transaction reappearing in the overlap
    // window (a true duplicate), the other is a genuinely different $5
    // coffee bought the same day that just happens to collide on
    // (date, amount, normalized description). Exactly one is a duplicate —
    // the other must import, not be silently dropped alongside it.
    const csvB = buildCsv([
      { date: "06/10/2026", description: "Coffee", amount: "-5.00" },
      { date: "06/10/2026", description: "Coffee", amount: "-5.00" },
    ]);
    const resB = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csvB), "mult-b.csv", {
      accountId: CASH_A(),
    });
    expect(resB.statusCode).toBe(201);
    expect(resB.json().lines).toEqual({ imported: 1, skippedDuplicates: 1 });

    // Confirm three rows now exist on the account for this key: the original
    // plus the one genuinely-new line that made it through.
    const rows = await db.admin.query(
      "SELECT count(*)::int AS n FROM bank_statement_lines WHERE company_id = $1 AND money_account_id = $2 AND posted_date = '2026-06-10' AND description = 'Coffee'",
      [db.companyA, CASH_A()],
    );
    expect(rows.rows[0].n).toBe(2);
  });
});

// ---------------------------------------------------------------------------
// matching engine
// ---------------------------------------------------------------------------

describe("matching engine", () => {
  it("suggests when exactly one candidate exists", async () => {
    await createLedgerEntry(db.companyA, "2026-07-03", "-77.77", EXP_A(), "Engine Exactly One");
    const ofx = buildOfx([{ fitid: "ENG-1", date: "2026-07-05", amount: "-77.77", name: "Engine Exactly One" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "engine1.ofx", { accountId: CASH_A() });
    expect(res.statusCode).toBe(201);
    expect(res.json().matches).toEqual({ suggested: 1, unmatched: 0 });

    const linesRes = await get(app, db.companyA, `/api/c/bank/statements/${res.json().statementId}/lines?status=suggested`);
    expect(linesRes.json().lines).toHaveLength(1);
    expect(linesRes.json().lines[0].suggestionEntry).not.toBeNull();
  });

  it("stays unmatched when two candidates exist (never guesses)", async () => {
    await createLedgerEntry(db.companyA, "2026-07-10", "-88.88", EXP_A(), "Engine Two A");
    await createLedgerEntry(db.companyA, "2026-07-11", "-88.88", EXP_A(), "Engine Two B");
    const ofx = buildOfx([{ fitid: "ENG-2", date: "2026-07-10", amount: "-88.88", name: "Engine Two Candidates" }]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "engine2.ofx", { accountId: CASH_A() });
    expect(res.statusCode).toBe(201);
    expect(res.json().matches).toEqual({ suggested: 0, unmatched: 1 });
  });

  it("excludes an entry already settled by another line", async () => {
    const { entryId } = await createLedgerEntry(db.companyA, "2026-07-20", "-99.99", EXP_A(), "Engine Settled");
    // Manually match a first statement line to this entry.
    const ofxFirst = buildOfx([{ fitid: "ENG-3A", date: "2026-07-20", amount: "-99.99", name: "Engine Settled" }]);
    const firstImport = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofxFirst), "engine3a.ofx", { accountId: CASH_A() });
    const firstLineId = (await get(app, db.companyA, `/api/c/bank/statements/${firstImport.json().statementId}/lines`)).json().lines[0].id;
    const matchRes = await post(app, db.companyA, `/api/c/bank/lines/${firstLineId}/match`, { entryId });
    expect(matchRes.statusCode).toBe(200);

    // A second, unrelated statement line with the SAME amount/date must not
    // be suggested this already-settled entry.
    const ofxSecond = buildOfx([{ fitid: "ENG-3B", date: "2026-07-20", amount: "-99.99", name: "Engine Settled Again" }]);
    const secondImport = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofxSecond), "engine3b.ofx", { accountId: CASH_A() });
    expect(secondImport.json().matches).toEqual({ suggested: 0, unmatched: 1 });
  });

  it("never suggests one entry to two lines of the same statement", async () => {
    await createLedgerEntry(db.companyA, "2026-07-25", "-55.55", EXP_A(), "Engine Shared Candidate");
    const ofx = buildOfx([
      { fitid: "ENG-4A", date: "2026-07-25", amount: "-55.55", name: "Line A" },
      { fitid: "ENG-4B", date: "2026-07-25", amount: "-55.55", name: "Line B" },
    ]);
    const res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "engine4.ofx", { accountId: CASH_A() });
    expect(res.statusCode).toBe(201);
    // Exactly one of the two lines gets the suggestion, never both.
    expect(res.json().matches).toEqual({ suggested: 1, unmatched: 1 });

    const linesRes = await get(app, db.companyA, `/api/c/bank/statements/${res.json().statementId}/lines`);
    const suggestedLines = linesRes.json().lines.filter((l: { status: string }) => l.status === "suggested");
    expect(suggestedLines).toHaveLength(1);
  });

  it("POST .../statements/:id/rematch re-runs the engine on demand", async () => {
    const ofx = buildOfx([{ fitid: "ENG-5", date: "2026-08-01", amount: "-33.33", name: "Rematch Me" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "engine5.ofx", { accountId: CASH_A() });
    const statementId = importRes.json().statementId;
    expect(importRes.json().matches).toEqual({ suggested: 0, unmatched: 1 }); // no entry yet

    await createLedgerEntry(db.companyA, "2026-08-02", "-33.33", EXP_A(), "Rematch Me");
    const rematchRes = await post(app, db.companyA, `/api/c/bank/statements/${statementId}/rematch`);
    expect(rematchRes.statusCode).toBe(200);
    expect(rematchRes.json().matches).toEqual({ suggested: 1, unmatched: 0 });
  });

  it("±7-day window boundary: an entry exactly 7 days away is a candidate (in), exactly 8 days away is not (out)", async () => {
    // Day 7 — in the window (BETWEEN is inclusive on both ends).
    await createLedgerEntry(db.companyA, "2026-09-01", "-707.07", EXP_A(), "Boundary Day 7");
    const in7 = buildOfx([{ fitid: "BOUND-7", date: "2026-09-08", amount: "-707.07", name: "Boundary Day 7" }]);
    const in7Res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(in7), "bound7.ofx", { accountId: CASH_A() });
    expect(in7Res.statusCode).toBe(201);
    expect(in7Res.json().matches).toEqual({ suggested: 1, unmatched: 0 });

    // Day 8 — one day outside the window; the entry must not be suggested.
    await createLedgerEntry(db.companyA, "2026-08-31", "-808.08", EXP_A(), "Boundary Day 8");
    const out8 = buildOfx([{ fitid: "BOUND-8", date: "2026-09-08", amount: "-808.08", name: "Boundary Day 8" }]);
    const out8Res = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(out8), "bound8.ofx", { accountId: CASH_A() });
    expect(out8Res.statusCode).toBe(201);
    expect(out8Res.json().matches).toEqual({ suggested: 0, unmatched: 1 });
  });
});

// ---------------------------------------------------------------------------
// confirm / confirm-all
// ---------------------------------------------------------------------------

describe("confirm / confirm-all", () => {
  it("confirm turns a suggestion into a match", async () => {
    await createLedgerEntry(db.companyA, "2026-08-10", "150.00", REV_A(), "Confirm Me");
    const ofx = buildOfx([{ fitid: "CONF-1", date: "2026-08-11", amount: "150.00", name: "Confirm Me" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "confirm1.ofx", { accountId: CASH_A() });
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const confirmRes = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/confirm`);
    expect(confirmRes.statusCode).toBe(200);

    const after = await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`);
    expect(after.json().lines[0].status).toBe("matched");
  });

  it("409s confirming a line with no suggestion", async () => {
    const ofx = buildOfx([{ fitid: "CONF-2", date: "2026-08-12", amount: "-10.00", name: "No Suggestion" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "confirm2.ofx", { accountId: CASH_A() });
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const res = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/confirm`);
    expect(res.statusCode).toBe(409);
  });

  it("confirm-all matches every suggestion and continues past a partial failure, reporting it in English", async () => {
    await createLedgerEntry(db.companyA, "2026-08-20", "-20.00", EXP_A(), "Confirm All A");
    await createLedgerEntry(db.companyA, "2026-08-21", "-21.00", EXP_A(), "Confirm All B");
    const ofx = buildOfx([
      { fitid: "CALL-A", date: "2026-08-20", amount: "-20.00", name: "Confirm All A" },
      { fitid: "CALL-B", date: "2026-08-21", amount: "-21.00", name: "Confirm All B" },
    ]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "confirmall.ofx", { accountId: CASH_A() });
    const statementId = importRes.json().statementId;
    expect(importRes.json().matches).toEqual({ suggested: 2, unmatched: 0 });

    const lines = (await get(app, db.companyA, `/api/c/bank/statements/${statementId}/lines`)).json().lines;
    const lineB = lines.find((l: { amount: string }) => l.amount === "-21.0000");
    expect(lineB.suggestionEntryId).not.toBeNull();

    // Sabotage line B's suggestion by getting a THIRD, unrelated statement
    // line suggested the SAME entry (the engine allows this across different
    // statements — settle-uniqueness isn't checked until an actual match),
    // then confirming that side line first. confirm-all's own attempt on
    // line B must then fail with "already settled by another statement
    // line", while line A still succeeds — the whole point of the SAVEPOINT
    // per line inside the route.
    const sideOfx = buildOfx([{ fitid: "CALL-SIDE", date: "2026-08-21", amount: "-21.00", name: "Confirm All B" }]);
    const sideImport = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(sideOfx), "side.ofx", { accountId: CASH_A() });
    expect(sideImport.json().matches).toEqual({ suggested: 1, unmatched: 0 });
    const sideLineId = (await get(app, db.companyA, `/api/c/bank/statements/${sideImport.json().statementId}/lines`)).json().lines[0].id;
    const sideConfirm = await post(app, db.companyA, `/api/c/bank/lines/${sideLineId}/confirm`);
    expect(sideConfirm.statusCode).toBe(200);

    const confirmAllRes = await post(app, db.companyA, `/api/c/bank/statements/${statementId}/confirm-all`);
    expect(confirmAllRes.statusCode).toBe(200);
    const body = confirmAllRes.json() as { results: Array<{ lineId: string; ok: boolean; error?: string }>; confirmed: number; failed: number };
    expect(body.confirmed).toBe(1);
    expect(body.failed).toBe(1);
    const failedResult = body.results.find((r) => !r.ok)!;
    expect(failedResult.error).toMatch(/already been matched to another statement line/i);
    const succeededResult = body.results.find((r) => r.ok)!;
    expect(succeededResult.lineId).not.toBe(failedResult.lineId);
  });
});

// ---------------------------------------------------------------------------
// suggestion staleness: the suggested entry gets reversed before the user
// acts on the suggestion
// ---------------------------------------------------------------------------

describe("suggestion staleness — the suggested entry gets reversed", () => {
  it("documents actual behavior: confirming a suggestion still succeeds even after the entry was reversed", async () => {
    // match_statement_line() (0011_bank_rec.sql) has no "was this entry
    // later reversed" check — the ORIGINAL entry's own line is untouched by
    // a reversal (CLAUDE.md §3.2: posted entries are immutable forever), so
    // it still legally satisfies every one of that function's own checks
    // (posted, exactly one line on the account, amount matches, not already
    // settled). This is deliberately left alone (see server/src/lib/
    // statementMatch.ts's file header) — changing match_statement_line()'s
    // own enforcement would be a new migration, a bigger and separate
    // decision than this fix. What DOES change (below) is that the engine
    // stops offering a reversed entry as a fresh SUGGESTION in the first
    // place.
    const { entryId } = await createLedgerEntry(db.companyA, "2026-09-10", "-64.00", EXP_A(), "Stale Suggestion A");
    const ofx = buildOfx([{ fitid: "STALE-A", date: "2026-09-11", amount: "-64.00", name: "Stale Suggestion A" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "stale-a.ofx", { accountId: CASH_A() });
    expect(importRes.json().matches).toEqual({ suggested: 1, unmatched: 0 });
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const reverseRes = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, { date: "2026-09-15" });
    expect(reverseRes.statusCode).toBe(201);

    const confirmRes = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/confirm`);
    expect(confirmRes.statusCode).toBe(200);
    const lineAfter = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0];
    expect(lineAfter).toMatchObject({ status: "matched", matchedEntryId: entryId });
  });

  it("rematch clears a stale suggestion once its entry has been reversed, instead of re-suggesting it", async () => {
    const { entryId } = await createLedgerEntry(db.companyA, "2026-09-20", "-88.00", EXP_A(), "Stale Suggestion B");
    const ofx = buildOfx([{ fitid: "STALE-B", date: "2026-09-21", amount: "-88.00", name: "Stale Suggestion B" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "stale-b.ofx", { accountId: CASH_A() });
    const statementId = importRes.json().statementId;
    expect(importRes.json().matches).toEqual({ suggested: 1, unmatched: 0 });
    const lineBefore = (await get(app, db.companyA, `/api/c/bank/statements/${statementId}/lines`)).json().lines[0];
    expect(lineBefore.suggestionEntryId).toBe(entryId);

    const reverseRes = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, { date: "2026-09-25" });
    expect(reverseRes.statusCode).toBe(201);

    const rematchRes = await post(app, db.companyA, `/api/c/bank/statements/${statementId}/rematch`);
    expect(rematchRes.statusCode).toBe(200);
    expect(rematchRes.json().matches).toEqual({ suggested: 0, unmatched: 1 });

    const lineAfter = (await get(app, db.companyA, `/api/c/bank/statements/${statementId}/lines`)).json().lines[0];
    expect(lineAfter.status).toBe("unmatched");
    expect(lineAfter.suggestionEntryId).toBeNull();

    // The manual-pick candidates list shares the same exclusion — the
    // reversed entry shouldn't even be offerable as a one-click pick.
    const candidatesRes = await get(app, db.companyA, `/api/c/bank/lines/${lineAfter.id}/candidates`);
    expect(candidatesRes.json().candidates).toEqual([]);
  });

  it("a FRESH import never suggests an entry that was already reversed before the statement arrived", async () => {
    const { entryId } = await createLedgerEntry(db.companyA, "2026-09-28", "-19.00", EXP_A(), "Stale Suggestion C");
    const reverseRes = await post(app, db.companyA, `/api/c/entries/${entryId}/reverse`, { date: "2026-09-29" });
    expect(reverseRes.statusCode).toBe(201);

    const ofx = buildOfx([{ fitid: "STALE-C", date: "2026-09-28", amount: "-19.00", name: "Stale Suggestion C" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "stale-c.ofx", { accountId: CASH_A() });
    expect(importRes.statusCode).toBe(201);
    expect(importRes.json().matches).toEqual({ suggested: 0, unmatched: 1 });
  });
});

// ---------------------------------------------------------------------------
// create-entry (both signs) with trial-balance cross-check
// ---------------------------------------------------------------------------

describe("create-entry from a statement line", () => {
  it("money IN: debits Cash, credits the category, cross-checked against the trial balance", async () => {
    const csv = buildCsv([{ date: "09/01/2026", description: "Client Deposit", amount: "300.00" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "create-in.csv", {
      accountId: CASH_A(), ...CSV_MAPPING_FIELDS,
    });
    expect(importRes.statusCode).toBe(201);
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const cashBefore = await tbBalance(db.companyA, "1000");
    const revBefore = await tbBalance(db.companyA, "4000");

    const res = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: REV_A() });
    expect(res.statusCode).toBe(201);
    expect(res.json().summary).toMatch(/in from/i);

    expect(await tbBalance(db.companyA, "1000")).toBe(addMoney(cashBefore, "300.0000"));
    // Revenue's raw (debit-positive) balance moves the OPPOSITE direction of
    // cash for a money-in: crediting revenue makes its raw balance more
    // negative, same convention as ap-payments.test.ts's own A/P comment.
    expect(await tbBalance(db.companyA, "4000")).toBe(addMoney(revBefore, "-300.0000"));

    const lineAfter = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0];
    expect(lineAfter.status).toBe("created");
  });

  it("money OUT: credits Cash, debits the category, cross-checked against the trial balance", async () => {
    const csv = buildCsv([{ date: "09/02/2026", description: "Office Depot", amount: "-64.20" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "create-out.csv", {
      accountId: CASH_A(),
    });
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const cashBefore = await tbBalance(db.companyA, "1000");
    const expBefore = await tbBalance(db.companyA, "6000");

    const res = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: EXP_A(), who: "Office Depot" });
    expect(res.statusCode).toBe(201);
    expect(res.json().summary).toMatch(/spent on/i);

    expect(await tbBalance(db.companyA, "1000")).toBe(addMoney(cashBefore, "-64.2000"));
    expect(await tbBalance(db.companyA, "6000")).toBe(addMoney(expBefore, "64.2000"));
  });

  it("422s when the category account is the money account itself", async () => {
    const csv = buildCsv([{ date: "09/03/2026", description: "Self Category", amount: "-5.00" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "create-self.csv", {
      accountId: CASH_A(),
    });
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;

    const res = await post(app, db.companyA, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: CASH_A() });
    expect(res.statusCode).toBe(422);
  });
});

// ---------------------------------------------------------------------------
// reconcile
// ---------------------------------------------------------------------------

/** A fresh company + starter chart, isolated from every other test's own
 *  postings on Cash — used by every reconcile test below so the book
 *  balance being reconciled against is exact and predictable. */
async function createIsolatedCompany(
  name: string,
): Promise<{ companyId: string; moneyAccountId: string; incomeAccountId: string }> {
  const compRes = await post(app, "", "/api/companies", { name });
  // /api/companies doesn't need X-Company-Id; post() always sends the
  // header, but companyContextPlugin only enforces it under /api/c/, so an
  // empty header here is harmless.
  expect(compRes.statusCode).toBe(201);
  const companyId = compRes.json().id;
  const freshRes = await post(app, companyId, "/api/c/setup/fresh", { template: "small-business" });
  expect(freshRes.statusCode).toBe(200);

  const accountsRes = await get(app, companyId, "/api/c/accounts?grouped=1");
  return {
    companyId,
    moneyAccountId: accountsRes.json().money[0].id as string,
    incomeAccountId: accountsRes.json().income[0].id as string,
  };
}

describe("reconcile + undo", () => {
  it("gates reconciling on zero unsettled lines with an English count", async () => {
    const csv = buildCsv([{ date: "10/01/2026", description: "Unsettled", amount: "12.00" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "gate.csv", {
      accountId: CASH_A(), ...CSV_MAPPING_FIELDS,
    });
    const statementId = importRes.json().statementId;

    const res = await post(app, db.companyA, `/api/c/bank/statements/${statementId}/reconcile`, {
      endingBalance: "0.00", throughDate: "2026-10-31",
    });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/still need a decision/i);
  });

  it("reconciles to the penny, then blocks an off-by-a-cent attempt with the English difference, then blocks a backdated entry until undone", async () => {
    const { companyId, moneyAccountId, incomeAccountId } = await createIsolatedCompany("Reconcile Isolated Co");

    // Seed one settled $500.00 deposit.
    const csv = buildCsv([{ date: "10/05/2026", description: "Opening Deposit", amount: "500.00" }]);
    const importRes = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csv), "recon.csv", {
      accountId: moneyAccountId, ...CSV_MAPPING_FIELDS,
    });
    const statementId = importRes.json().statementId;
    const lineId = (await get(app, companyId, `/api/c/bank/statements/${statementId}/lines`)).json().lines[0].id;
    const createRes = await post(app, companyId, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: incomeAccountId });
    expect(createRes.statusCode).toBe(201);

    // Off by one cent — 409, with the English difference and a numeric one.
    const offByOne = await post(app, companyId, `/api/c/bank/statements/${statementId}/reconcile`, {
      endingBalance: "500.01", throughDate: "2026-10-31",
    });
    expect(offByOne.statusCode).toBe(409);
    expect(offByOne.json().error).toMatch(/doesn't add up/i);
    expect(offByOne.json().difference).toBe("0.01");

    // Exact match — succeeds.
    const okRes = await post(app, companyId, `/api/c/bank/statements/${statementId}/reconcile`, {
      endingBalance: "500.00", throughDate: "2026-10-31",
    });
    expect(okRes.statusCode).toBe(200);
    expect(okRes.json().summary).toMatch(/reconciled through 2026-10-31/i);

    // A new entry dated on/before the reconciled-through date on this
    // account is refused.
    const backdated = await post(app, companyId, "/api/c/entries", {
      kind: "money-out", date: "2026-10-15", amount: "10.00",
      fromAccountId: moneyAccountId, categoryAccountId: incomeAccountId,
    });
    expect(backdated.statusCode).toBe(409);
    expect(backdated.json().error).toMatch(/reconciled through 2026-10-31/i);

    // Undo requires confirm: true.
    const undoNoConfirm = await post(app, companyId, "/api/c/bank/undo-reconciliation", { accountId: moneyAccountId });
    expect(undoNoConfirm.statusCode).toBe(400);

    const undoRes = await post(app, companyId, "/api/c/bank/undo-reconciliation", { accountId: moneyAccountId, confirm: true });
    expect(undoRes.statusCode).toBe(200);

    // The overview reflects "never reconciled" again (no prior reconciliation
    // to fall back to), and the previously-blocked entry now succeeds.
    const overviewAfterUndo = await get(app, companyId, "/api/c/bank/overview");
    const acct = overviewAfterUndo.json().accounts.find((a: { accountId: string }) => a.accountId === moneyAccountId);
    expect(acct.reconciledThrough).toBeNull();

    const backdatedAgain = await post(app, companyId, "/api/c/entries", {
      kind: "money-out", date: "2026-10-15", amount: "10.00",
      fromAccountId: moneyAccountId, categoryAccountId: incomeAccountId,
    });
    expect(backdatedAgain.statusCode).toBe(201);

    // Second undo (nothing left to undo) is refused.
    const undoAgain = await post(app, companyId, "/api/c/bank/undo-reconciliation", { accountId: moneyAccountId, confirm: true });
    expect(undoAgain.statusCode).toBe(409);
  });

  it("409s in the OTHER direction — books exceed the bank — with the matching English and a negative signed difference", async () => {
    const { companyId, moneyAccountId, incomeAccountId } = await createIsolatedCompany("Reconcile Books Exceed Co");

    // Two settled deposits totaling $500.00 in the books...
    const csv = buildCsv([{ date: "11/05/2026", description: "Deposit One", amount: "500.00" }]);
    const importRes = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csv), "books-exceed.csv", {
      accountId: moneyAccountId, ...CSV_MAPPING_FIELDS,
    });
    const statementId = importRes.json().statementId;
    const lineId = (await get(app, companyId, `/api/c/bank/statements/${statementId}/lines`)).json().lines[0].id;
    const createRes = await post(app, companyId, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: incomeAccountId });
    expect(createRes.statusCode).toBe(201);

    // ...but the bank statement itself only claims $480.00 — the books have
    // MORE than the bank does. v_diff = ending - book = 480 - 500 = -20.
    const res = await post(app, companyId, `/api/c/bank/statements/${statementId}/reconcile`, {
      endingBalance: "480.00", throughDate: "2026-11-30",
    });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe(
      "this doesn't add up — $20.00 more is in your books than came in at the bank",
    );
    expect(res.json().difference).toBe("-20.00");
  });

  it("guard inheritance: create-entry from a line dated inside the account's own reconciled window is refused", async () => {
    // create_entry_from_line() (0011_bank_rec.sql) posts its balanced entry
    // via post_entry() — the SAME function every other posting path in this
    // codebase uses — rather than writing to journal_lines/journal_entries
    // directly, so 0011's assert_not_reconciled() guard on post_entry()
    // applies to it automatically, with no bank-rec-specific code duplicating
    // that check. This proves the inheritance actually holds at the API
    // layer, not just by reading the SQL.
    const { companyId, moneyAccountId, incomeAccountId } = await createIsolatedCompany("Guard Inheritance Co");

    // Settle and reconcile one December deposit through month end.
    const csv = buildCsv([{ date: "12/05/2026", description: "December Deposit", amount: "500.00" }]);
    const importRes = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csv), "guard.csv", {
      accountId: moneyAccountId, ...CSV_MAPPING_FIELDS,
    });
    const firstStatementId = importRes.json().statementId;
    const firstLineId = (await get(app, companyId, `/api/c/bank/statements/${firstStatementId}/lines`)).json().lines[0].id;
    const firstCreate = await post(app, companyId, `/api/c/bank/lines/${firstLineId}/create-entry`, { categoryAccountId: incomeAccountId });
    expect(firstCreate.statusCode).toBe(201);

    const reconcileRes = await post(app, companyId, `/api/c/bank/statements/${firstStatementId}/reconcile`, {
      endingBalance: "500.00", throughDate: "2026-12-31",
    });
    expect(reconcileRes.statusCode).toBe(200);

    // A SECOND, later-arriving statement carries a line dated INSIDE the
    // now-reconciled window (a plausible real scenario: a transaction that
    // cleared late, or an overlapping re-export) — still unmatched, still
    // legally a "new" line by bank_statement_lines' own rules (nothing about
    // reconciling account X blocks IMPORTING more lines against it, only
    // POSTING new entries into the reconciled window).
    const lateCsv = buildCsv([{ date: "12/10/2026", description: "Late Arriving Charge", amount: "-15.00" }]);
    const lateImportRes = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(lateCsv), "guard-late.csv", {
      accountId: moneyAccountId,
    });
    expect(lateImportRes.statusCode).toBe(201);
    const lateLineId = (await get(app, companyId, `/api/c/bank/statements/${lateImportRes.json().statementId}/lines`)).json().lines[0].id;

    const blockedCreate = await post(app, companyId, `/api/c/bank/lines/${lateLineId}/create-entry`, { categoryAccountId: incomeAccountId });
    expect(blockedCreate.statusCode).toBe(409);
    expect(blockedCreate.json().error).toMatch(/reconciled through 2026-12-31 — date it after 2026-12-31 or undo the reconciliation/i);

    // Nothing was created — the line is untouched, still unmatched.
    const lateLineAfter = (await get(app, companyId, `/api/c/bank/statements/${lateImportRes.json().statementId}/lines`)).json().lines[0];
    expect(lateLineAfter.status).toBe("unmatched");
    expect(lateLineAfter.matchedEntryId).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// overview
// ---------------------------------------------------------------------------

describe("GET /api/c/bank/overview", () => {
  it("lists every money account with its book balance, reconciled-through, and open statements", async () => {
    const res = await get(app, db.companyA, "/api/c/bank/overview");
    expect(res.statusCode).toBe(200);
    const accounts = res.json().accounts;
    const cash = accounts.find((a: { accountId: string }) => a.accountId === CASH_A());
    expect(cash).toBeDefined();
    expect(typeof cash.bookBalance).toBe("string");
    expect(Array.isArray(cash.openStatements)).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// GET /api/c/bank/accounts/:id/book-balance?asOf= — Task 5's reconcile
// footer feed.
// ---------------------------------------------------------------------------

describe("GET /api/c/bank/accounts/:id/book-balance", () => {
  it("returns the account's book balance as of a given date, matching account_book_balance_asof() directly", async () => {
    const { companyId, moneyAccountId, incomeAccountId } = await createIsolatedCompany("Book Balance Co");
    const csv = buildCsv([{ date: "12/20/2026", description: "Deposit", amount: "250.00" }]);
    const importRes = await postMultipart(app, companyId, "/api/c/bank/statements", Buffer.from(csv), "bal.csv", {
      accountId: moneyAccountId, ...CSV_MAPPING_FIELDS,
    });
    const lineId = (await get(app, companyId, `/api/c/bank/statements/${importRes.json().statementId}/lines`)).json().lines[0].id;
    await post(app, companyId, `/api/c/bank/lines/${lineId}/create-entry`, { categoryAccountId: incomeAccountId });

    const res = await get(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/book-balance?asOf=2026-12-31`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body).toEqual({ accountId: moneyAccountId, asOf: "2026-12-31", bookBalance: "250.0000" });

    // Before the entry's own date, the balance is zero — proves this is a
    // real as-of computation, not just "the current balance" echoed back.
    const before = await get(app, companyId, `/api/c/bank/accounts/${moneyAccountId}/book-balance?asOf=2026-12-19`);
    expect(before.json().bookBalance).toBe("0.0000");
  });

  it("defaults asOf to today when omitted", async () => {
    const res = await get(app, db.companyA, `/api/c/bank/accounts/${CASH_A()}/book-balance`);
    expect(res.statusCode).toBe(200);
    expect(res.json().asOf).toBeNull();
    expect(typeof res.json().bookBalance).toBe("string");
  });

  it("404s an unknown account id", async () => {
    const res = await get(app, db.companyA, "/api/c/bank/accounts/999999999/book-balance");
    expect(res.statusCode).toBe(404);
    expect(res.json().error).toMatch(/couldn't find that account/i);
  });

  it("400s a malformed asOf", async () => {
    const res = await get(app, db.companyA, `/api/c/bank/accounts/${CASH_A()}/book-balance?asOf=not-a-date`);
    expect(res.statusCode).toBe(400);
  });
});

// ---------------------------------------------------------------------------
// RLS isolation
// ---------------------------------------------------------------------------

describe("RLS isolation", () => {
  it("a query without a company filter returns zero rows for another company's statements/lines", async () => {
    const csv = buildCsv([{ date: "11/01/2026", description: "RLS Test", amount: "42.00" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(csv), "rls.csv", {
      accountId: CASH_A(), ...CSV_MAPPING_FIELDS,
    });
    expect(importRes.statusCode).toBe(201);

    const scopedToB = await db.appClient(db.companyB);
    const seenByB = await scopedToB.query("SELECT count(*)::int AS n FROM bank_statements");
    expect(seenByB.rows[0].n).toBe(0);
    const seenByBLines = await scopedToB.query("SELECT count(*)::int AS n FROM bank_statement_lines");
    expect(seenByBLines.rows[0].n).toBe(0);

    // Company B genuinely cannot fetch company A's statement via the API either.
    const crossFetch = await get(app, db.companyB, `/api/c/bank/statements/${importRes.json().statementId}/lines`);
    expect(crossFetch.statusCode).toBe(404);
  });
});

// ---------------------------------------------------------------------------
// concurrency
// ---------------------------------------------------------------------------

describe("concurrency", () => {
  it("two simultaneous confirms of the same suggested line: exactly one succeeds, the other gets a clean English failure", async () => {
    await createLedgerEntry(db.companyA, "2026-12-01", "-17.00", EXP_A(), "Concurrent Confirm");
    const ofx = buildOfx([{ fitid: "CONC-1", date: "2026-12-01", amount: "-17.00", name: "Concurrent Confirm" }]);
    const importRes = await postMultipart(app, db.companyA, "/api/c/bank/statements", Buffer.from(ofx), "conc1.ofx", { accountId: CASH_A() });
    expect(importRes.json().matches.suggested).toBe(1);
    const lineId = (await get(app, db.companyA, `/api/c/bank/statements/${importRes.json().statementId}/lines?status=suggested`)).json().lines[0].id;

    const [r1, r2] = await Promise.all([
      post(app, db.companyA, `/api/c/bank/lines/${lineId}/confirm`),
      post(app, db.companyA, `/api/c/bank/lines/${lineId}/confirm`),
    ]);
    const statuses = [r1.statusCode, r2.statusCode].sort();
    expect(statuses).toEqual([200, 409]);
    const failure = r1.statusCode === 409 ? r1 : r2;
    expect(failure.json().error.length).toBeGreaterThan(0);
  });

  it("concurrent imports of the identical file: exactly one succeeds, the other gets the sha-unique English", async () => {
    const ofx = buildOfx([{ fitid: "CONC-FILE", date: "2026-12-05", amount: "-1.00", name: "Concurrent File" }]);
    const buf = Buffer.from(ofx);
    const [r1, r2] = await Promise.all([
      postMultipart(app, db.companyA, "/api/c/bank/statements", buf, "concfile.ofx", { accountId: CASH_A() }),
      postMultipart(app, db.companyA, "/api/c/bank/statements", buf, "concfile.ofx", { accountId: CASH_A() }),
    ]);
    const statuses = [r1.statusCode, r2.statusCode].sort();
    expect(statuses).toEqual([201, 409]);
    const failure = r1.statusCode === 409 ? r1 : r2;
    expect(failure.json().error).toMatch(/already been imported/i);
  });
});
