import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db.js";
// Races below hold a statement open across an await. inFlight() attaches its
// rejection handler at creation time, which is what stops an early failure from
// becoming an unhandled rejection that silently kills this whole worker — see
// the header of tests/helpers/in-flight.ts.
import { inFlight } from "../helpers/in-flight.js";
import type { Client } from "pg";

let db: TestDb;
let c: Client; // app client scoped to companyA

beforeAll(async () => {
  db = await createTestDb();
  c = await db.appClient(db.companyA);
}, 60_000);
afterAll(async () => {
  await db.close();
});

// ---- local helpers -------------------------------------------------------

async function createVendor(
  client: Client, companyId: string, name: string, termsDays = 30,
): Promise<number> {
  const r = await client.query(
    `INSERT INTO vendors (company_id, name, terms_days) VALUES ($1, $2, $3) RETURNING id`,
    [companyId, name, termsDays]);
  return Number(r.rows[0].id);
}

interface LineSpec { description?: string; quantity?: string; unitPrice: string; amount: string; categoryAccountId: number }

async function draftBill(
  client: Client, companyId: string, userId: string, vendorId: number,
  opts: { billDate?: string; dueDate?: string; memo?: string; documentOnly?: boolean; refNo?: string; lines: LineSpec[] },
): Promise<number> {
  const billDate = opts.billDate ?? "2024-05-01";
  const dueDate = opts.dueDate ?? "2024-05-31";
  const r = await client.query(
    `INSERT INTO bills (company_id, vendor_id, ref_no, bill_date, due_date, memo, document_only, created_by)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
    [companyId, vendorId, opts.refNo ?? null, billDate, dueDate, opts.memo ?? null, opts.documentOnly ?? false, userId]);
  const billId = Number(r.rows[0].id);
  for (const l of opts.lines) {
    await client.query(
      `INSERT INTO bill_lines
         (company_id, bill_id, description, quantity, unit_price, amount, category_account_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7)`,
      [companyId, billId, l.description ?? "item", l.quantity ?? "1", l.unitPrice, l.amount, l.categoryAccountId]);
  }
  return billId;
}

async function issueBill(client: Client, companyId: string, billId: number, userId: string): Promise<void> {
  await client.query("SELECT issue_bill($1, $2, $3)", [companyId, billId, userId]);
}

interface AppSpec { billId: number; amount: string }
async function payBills(
  client: Client, companyId: string, userId: string,
  spec: {
    vendorId: number; paymentDate?: string; amount: string; moneyAccountId: number;
    method?: "check" | "electronic"; checkNo?: number; memo?: string; applications?: AppSpec[];
  },
): Promise<number> {
  const jsonSpec = {
    vendor_id: spec.vendorId,
    payment_date: spec.paymentDate ?? "2024-05-15",
    amount: spec.amount,
    money_account_id: spec.moneyAccountId,
    method: spec.method ?? "electronic",
    check_no: spec.checkNo ?? null,
    memo: spec.memo ?? null,
    applications: (spec.applications ?? []).map((a) => ({ bill_id: a.billId, amount: a.amount })),
  };
  const r = await client.query(
    "SELECT pay_bills($1, $2::jsonb, $3) AS id", [companyId, JSON.stringify(jsonSpec), userId]);
  return Number(r.rows[0].id);
}

async function openBalance(client: Client, companyId: string, billId: number): Promise<string> {
  const r = await client.query(
    "SELECT bill_open_balance($1, $2) AS bal", [companyId, billId]);
  return r.rows[0].bal;
}

async function unappliedBalance(client: Client, companyId: string, paymentId: number): Promise<string> {
  const r = await client.query(
    "SELECT bill_payment_unapplied_balance($1, $2) AS bal", [companyId, paymentId]);
  return r.rows[0].bal;
}

async function applyVendorCredit(
  client: Client, companyId: string, paymentId: number, apps: AppSpec[], userId: string,
): Promise<void> {
  await client.query(
    "SELECT apply_vendor_credit($1, $2, $3::jsonb, $4)",
    [companyId, paymentId, JSON.stringify(apps.map((a) => ({ bill_id: a.billId, amount: a.amount }))), userId]);
}

/** A fresh asset ("bank") account for a company, via the admin client. Used
 * wherever a test needs its OWN check-number space (manual check_no /
 * set_next_check_no / assign_check_numbers) isolated from every other test
 * in this file — check numbers are unique per (company, money account), so
 * reusing the shared fixture Cash account (db.accounts["1000"]) across many
 * such tests would make later tests' counters depend on what earlier tests
 * happened to set, purely by file order. */
let moneyAccountSeq = 0;
async function createMoneyAccount(companyId: string): Promise<number> {
  moneyAccountSeq += 1;
  const r = await db.admin.query(
    `INSERT INTO accounts (company_id, code, name, type) VALUES ($1, $2, $3, 'asset') RETURNING id`,
    [companyId, "BANK-" + moneyAccountSeq, "Test Bank " + moneyAccountSeq]);
  return Number(r.rows[0].id);
}

/** A fresh company + chart, built directly via the admin (superuser) client so
 * tests can control exactly which accounts exist — needed for the
 * ensure_ap_account() ambiguity/absence tests, since companyA/companyB's
 * shared chart already resolves and caches an A/P account in company_settings
 * well before those tests run. */
async function createScratchCompany(
  name: string, apAccounts: Array<{ name: string; active?: boolean }>,
): Promise<{ companyId: string; expenseAccountId: number; cashAccountId: number }> {
  const comp = await db.admin.query(`INSERT INTO companies (name) VALUES ($1) RETURNING id`, [name]);
  const companyId: string = comp.rows[0].id;
  for (const a of apAccounts) {
    await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type, active)
       VALUES ($1, $2, $3, 'liability', $4)`,
      [companyId, "AP-" + Math.random().toString(36).slice(2, 8), a.name, a.active ?? true]);
  }
  const cash = await db.admin.query(
    `INSERT INTO accounts (company_id, code, name, type) VALUES ($1, '1000', 'Cash', 'asset') RETURNING id`,
    [companyId]);
  const exp = await db.admin.query(
    `INSERT INTO accounts (company_id, code, name, type) VALUES ($1, '6000', 'Operating Expense', 'expense') RETURNING id`,
    [companyId]);
  return {
    companyId,
    expenseAccountId: Number(exp.rows[0].id),
    cashAccountId: Number(cash.rows[0].id),
  };
}

// ---- tests ----------------------------------------------------------------

describe("vendors", () => {
  it("vendor names are unique per company but not across companies", async () => {
    await createVendor(c, db.companyA, "Acme Supply Co");
    await expect(createVendor(c, db.companyA, "Acme Supply Co")).rejects.toThrow(/duplicate key|unique/);
    const cB = await db.appClient(db.companyB);
    await expect(createVendor(cB, db.companyB, "Acme Supply Co")).resolves.toBeGreaterThan(0);
  });
});

describe("bill_lines category-account enforcement", () => {
  it("rejects a line posting to the company's own A/P account", async () => {
    const vend = await createVendor(c, db.companyA, "Bad Account Vendor");
    await expect(
      draftBill(c, db.companyA, db.userA, vend, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["2000"].A }],
      }),
    ).rejects.toThrow(/Accounts Payable account itself/);
  });

  it("rejects a line posting to an account literally named 'Accounts Payable' even before ap_account_id is resolved", async () => {
    const scratch = await createScratchCompany("Unresolved AP Co", [{ name: "Accounts Payable" }]);
    const cScratch = await db.appClient(scratch.companyId);
    const apAccount = await db.admin.query(
      `SELECT id FROM accounts WHERE company_id = $1 AND name = 'Accounts Payable'`, [scratch.companyId]);
    const vend = await createVendor(cScratch, scratch.companyId, "Some Vendor");
    await expect(
      draftBill(cScratch, scratch.companyId, db.userA, vend, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: Number(apAccount.rows[0].id) }],
      }),
    ).rejects.toThrow(/Accounts Payable account itself/);
  });
});

describe("company_settings A/P account", () => {
  it("auto-detects the 'Accounts Payable' liability account on first use", async () => {
    const vend = await createVendor(c, db.companyA, "Auto Detect Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const r = await c.query("SELECT ap_account_id FROM company_settings WHERE company_id = $1", [db.companyA]);
    expect(Number(r.rows[0].ap_account_id)).toBe(db.accounts["2000"].A);
  });

  it("rejects setting ap_account_id to a non-liability account", async () => {
    await expect(
      c.query(
        `INSERT INTO company_settings (company_id, ap_account_id) VALUES ($1, $2)
         ON CONFLICT (company_id) DO UPDATE SET ap_account_id = excluded.ap_account_id`,
        [db.companyA, db.accounts["6000"].A]),
    ).rejects.toThrow(/liability account/);
  });
});

describe("issue_bill", () => {
  it("refuses to issue a draft with no lines", async () => {
    const vend = await createVendor(c, db.companyA, "No Lines Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, { lines: [] });
    await expect(issueBill(c, db.companyA, bill, db.userA)).rejects.toThrow(/no lines/);
  });

  it("posts a balanced entry: credit A/P total, debit each line's category account", async () => {
    const vend = await createVendor(c, db.companyA, "Balanced Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [
        { unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A },
        { unitPrice: "50.0000", amount: "50.0000", categoryAccountId: db.accounts["6000"].A },
      ],
    });
    await issueBill(c, db.companyA, bill, db.userA);

    const row = await c.query(
      "SELECT status, posted_entry_id, issued_on FROM bills WHERE id = $1", [bill]);
    expect(row.rows[0].status).toBe("open");
    expect(row.rows[0].issued_on).not.toBeNull();
    const entryId = Number(row.rows[0].posted_entry_id);
    expect(entryId).toBeGreaterThan(0);

    const lines = await c.query(
      `SELECT account_id, debit, credit FROM journal_lines WHERE entry_id = $1 ORDER BY credit DESC`,
      [entryId]);
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["2000"].A), debit: "0.0000", credit: "150.0000" },
      { account_id: String(db.accounts["6000"].A), debit: "150.0000", credit: "0.0000" },
    ]);

    const entryStatus = await c.query("SELECT status FROM journal_entries WHERE id = $1", [entryId]);
    expect(entryStatus.rows[0].status).toBe("posted");
  });

  it("groups multiple lines by category account into one debit line", async () => {
    const vend = await createVendor(c, db.companyA, "Grouping Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [
        { unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A },
        { unitPrice: "20.0000", amount: "20.0000", categoryAccountId: db.accounts["6000"].A },
      ],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const row = await c.query("SELECT posted_entry_id FROM bills WHERE id = $1", [bill]);
    const lines = await c.query(
      "SELECT count(*)::int AS n FROM journal_lines WHERE entry_id = $1", [row.rows[0].posted_entry_id]);
    expect(lines.rows[0].n).toBe(2); // one A/P credit line + one grouped debit line, not two debits
  });

  it("cannot issue an already-issued bill", async () => {
    const vend = await createVendor(c, db.companyA, "Double Issue Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(issueBill(c, db.companyA, bill, db.userA)).rejects.toThrow(/already/);
  });

  it("document_only bills are marked issued but post NO ledger entry", async () => {
    const vend = await createVendor(c, db.companyA, "History Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      documentOnly: true,
      lines: [{ unitPrice: "2789.80", amount: "2789.80", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const row = await c.query(
      "SELECT status, issued_on, posted_entry_id FROM bills WHERE id = $1", [bill]);
    expect(row.rows[0].status).toBe("open");
    expect(row.rows[0].issued_on).not.toBeNull();
    expect(row.rows[0].posted_entry_id).toBeNull();
  });

  it("bills carry the vendor's own ref_no, unique per (company, vendor) when set, with no internal counter", async () => {
    const vend = await createVendor(c, db.companyA, "Ref No Vendor");
    const bill1 = await draftBill(c, db.companyA, db.userA, vend, {
      refNo: "INV-100",
      lines: [{ unitPrice: "5.0000", amount: "5.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill1, db.userA);
    await expect(
      draftBill(c, db.companyA, db.userA, vend, {
        refNo: "INV-100",
        lines: [{ unitPrice: "5.0000", amount: "5.0000", categoryAccountId: db.accounts["6000"].A }],
      }),
    ).rejects.toThrow(/duplicate key|unique/);

    // a different vendor may reuse the same ref_no
    const vend2 = await createVendor(c, db.companyA, "Ref No Vendor Two");
    await expect(
      draftBill(c, db.companyA, db.userA, vend2, {
        refNo: "INV-100",
        lines: [{ unitPrice: "5.0000", amount: "5.0000", categoryAccountId: db.accounts["6000"].A }],
      }),
    ).resolves.toBeGreaterThan(0);

    // multiple bills with NO ref_no coexist freely
    await expect(
      draftBill(c, db.companyA, db.userA, vend, {
        lines: [{ unitPrice: "5.0000", amount: "5.0000", categoryAccountId: db.accounts["6000"].A }],
      }),
    ).resolves.toBeGreaterThan(0);
    await expect(
      draftBill(c, db.companyA, db.userA, vend, {
        lines: [{ unitPrice: "5.0000", amount: "5.0000", categoryAccountId: db.accounts["6000"].A }],
      }),
    ).resolves.toBeGreaterThan(0);
  });
});

describe("issued-bill immutability (mirrors CLAUDE.md §3.2)", () => {
  it("header fields are frozen once issued", async () => {
    const vend = await createVendor(c, db.companyA, "Frozen Header Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      c.query("UPDATE bills SET memo = 'edited' WHERE id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("UPDATE bills SET due_date = '2099-01-01' WHERE id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM bills WHERE id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
  });

  it("lines are frozen once the bill is issued", async () => {
    const vend = await createVendor(c, db.companyA, "Frozen Lines Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      c.query("UPDATE bill_lines SET amount = '999.0000' WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM bill_lines WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query(
        `INSERT INTO bill_lines (company_id, bill_id, unit_price, amount, category_account_id)
         VALUES ($1, $2, '1.0000', '1.0000', $3)`,
        [db.companyA, bill, db.accounts["6000"].A]),
    ).rejects.toThrow(/immutable/);
  });

  it("even the superuser cannot modify an issued bill or its lines", async () => {
    const vend = await createVendor(c, db.companyA, "Superuser Proof Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      db.admin.query("UPDATE bills SET memo = 'sneaky' WHERE id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      db.admin.query("UPDATE bill_lines SET amount = '1.0000' WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
  });

  it("drafts remain fully editable", async () => {
    const vend = await createVendor(c, db.companyA, "Still Draft Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await c.query("UPDATE bills SET memo = 'still editable' WHERE id = $1", [bill]);
    await c.query("UPDATE bill_lines SET amount = '20.0000', unit_price='20.0000' WHERE bill_id = $1", [bill]);
    await c.query("DELETE FROM bill_lines WHERE bill_id = $1", [bill]);
    await c.query("DELETE FROM bills WHERE id = $1", [bill]);
  });

  it("TRUNCATE is forbidden on every AP table, even for the superuser", async () => {
    for (const table of [
      "vendors", "check_counters", "bills", "bill_lines",
      "bill_payments", "bill_payment_applications",
    ]) {
      await expect(db.admin.query(`TRUNCATE ${table}`))
        .rejects.toThrow(/TRUNCATE is not allowed|foreign key/);
    }
  });
});

describe("void_bill", () => {
  it("reverses the posted entry and restores account balances", async () => {
    const vend = await createVendor(c, db.companyA, "Void Me Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "300.0000", amount: "300.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    const before = await c.query(
      `SELECT coalesce(sum(credit-debit),0)::numeric(19,4) AS bal FROM journal_lines l
         JOIN journal_entries e ON e.id = l.entry_id WHERE e.status='posted' AND l.account_id = $1`,
      [db.accounts["2000"].A]);
    await issueBill(c, db.companyA, bill, db.userA);

    await c.query("SELECT void_bill($1, $2, '2024-05-10', $3)", [db.companyA, bill, db.userA]);

    const row = await c.query("SELECT status, void_entry_id FROM bills WHERE id = $1", [bill]);
    expect(row.rows[0].status).toBe("void");
    expect(Number(row.rows[0].void_entry_id)).toBeGreaterThan(0);

    const after = await c.query(
      `SELECT coalesce(sum(credit-debit),0)::numeric(19,4) AS bal FROM journal_lines l
         JOIN journal_entries e ON e.id = l.entry_id WHERE e.status='posted' AND l.account_id = $1`,
      [db.accounts["2000"].A]);
    expect(after.rows[0].bal).toBe(before.rows[0].bal); // restored to pre-issue balance
  });

  it("refuses to void a bill with any payment application", async () => {
    const vend = await createVendor(c, db.companyA, "Applied Void Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "100.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "100.0000" }],
    });
    await expect(
      c.query("SELECT void_bill($1, $2, '2024-05-20', $3)", [db.companyA, bill, db.userA]),
    ).rejects.toThrow(/payments applied to it first/);
  });

  it("document_only void just flips status, with no reversing entry", async () => {
    const vend = await createVendor(c, db.companyA, "History Void Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      documentOnly: true,
      lines: [{ unitPrice: "500.0000", amount: "500.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await c.query("SELECT void_bill($1, $2, '2024-05-20', $3)", [db.companyA, bill, db.userA]);
    const row = await c.query("SELECT status, void_entry_id FROM bills WHERE id = $1", [bill]);
    expect(row.rows[0].status).toBe("void");
    expect(row.rows[0].void_entry_id).toBeNull();
  });

  it("cannot void a draft or an already-void bill", async () => {
    const vend = await createVendor(c, db.companyA, "Cant Void Draft Vendor");
    const draft = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await expect(
      c.query("SELECT void_bill($1, $2, '2024-05-20', $3)", [db.companyA, draft, db.userA]),
    ).rejects.toThrow(/only open bills/);
  });
});

describe("pay_bills and bill_payment_applications", () => {
  it("full single-bill payment closes the bill's open balance to zero", async () => {
    const vend = await createVendor(c, db.companyA, "Full Pay Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "200.0000", amount: "200.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "200.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "200.0000" }],
    });
    expect(await openBalance(c, db.companyA, bill)).toBe("0.0000");
  });

  it("a partial payment leaves the remainder open", async () => {
    const vend = await createVendor(c, db.companyA, "Partial Pay Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "200.0000", amount: "200.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "75.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "75.0000" }],
    });
    expect(await openBalance(c, db.companyA, bill)).toBe("125.0000");
  });

  it("one payment can cover several bills", async () => {
    const vend = await createVendor(c, db.companyA, "Multi Bill Vendor");
    const bill1 = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "60.0000", amount: "60.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    const bill2 = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "40.0000", amount: "40.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill1, db.userA);
    await issueBill(c, db.companyA, bill2, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "100.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill1, amount: "60.0000" }, { billId: bill2, amount: "40.0000" }],
    });
    expect(await openBalance(c, db.companyA, bill1)).toBe("0.0000");
    expect(await openBalance(c, db.companyA, bill2)).toBe("0.0000");
  });

  it("posts debit A/P / credit money account for the payment", async () => {
    const vend = await createVendor(c, db.companyA, "Posting Check Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "90.0000", amount: "90.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "90.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "90.0000" }],
    });
    const row = await c.query("SELECT posted_entry_id FROM bill_payments WHERE id = $1", [payId]);
    const lines = await c.query(
      "SELECT account_id, debit, credit FROM journal_lines WHERE entry_id = $1 ORDER BY debit DESC",
      [row.rows[0].posted_entry_id]);
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["2000"].A), debit: "90.0000", credit: "0.0000" },
      { account_id: String(db.accounts["1000"].A), debit: "0.0000", credit: "90.0000" },
    ]);
  });

  it("an overpayment with no application at all is a pure vendor credit", async () => {
    const vend = await createVendor(c, db.companyA, "Pure Credit Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "500.0000", moneyAccountId: db.accounts["1000"].A,
    });
    const r = await c.query(
      "SELECT bill_payment_unapplied_balance($1, $2) AS bal", [db.companyA, payId]);
    expect(r.rows[0].bal).toBe("500.0000");
  });

  it("rejects a money account that is not an asset", async () => {
    const vend = await createVendor(c, db.companyA, "Bad Money Account Vendor");
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "10.0000", moneyAccountId: db.accounts["6000"].A,
      }),
    ).rejects.toThrow(/asset account/);
  });

  it("rejects a payment method other than check or electronic", async () => {
    const vend = await createVendor(c, db.companyA, "Bad Method Vendor");
    await expect(
      c.query(
        "SELECT pay_bills($1, $2::jsonb, $3)",
        [db.companyA, JSON.stringify({
          vendor_id: vend, payment_date: "2024-05-15", amount: "10.0000",
          money_account_id: db.accounts["1000"].A, method: "cash", memo: null, applications: [],
        }), db.userA]),
    ).rejects.toThrow(/check.*or.*electronic/);
  });

  it("rejects an application to a bill belonging to a different vendor", async () => {
    const vendA = await createVendor(c, db.companyA, "Vendor Alpha");
    const vendB = await createVendor(c, db.companyA, "Vendor Beta");
    const bill = await draftBill(c, db.companyA, db.userA, vendA, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vendB, amount: "50.0000", moneyAccountId: db.accounts["1000"].A,
        applications: [{ billId: bill, amount: "50.0000" }],
      }),
    ).rejects.toThrow(/different vendor|does not belong/);
  });
});

describe("check payments", () => {
  it("method='check' with no check_no starts check_status='to_print'", async () => {
    const vend = await createVendor(c, db.companyA, "To Print Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A, method: "check",
    });
    const row = await c.query(
      "SELECT method, check_no, check_status, printed_on FROM bill_payments WHERE id = $1", [payId]);
    expect(row.rows[0].method).toBe("check");
    expect(row.rows[0].check_no).toBeNull();
    expect(row.rows[0].check_status).toBe("to_print");
    expect(row.rows[0].printed_on).toBeNull();
  });

  it("method='check' with a check_no starts check_status='manual'", async () => {
    const vend = await createVendor(c, db.companyA, "Manual Check Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A, method: "check", checkNo: 1001,
    });
    const row = await c.query(
      "SELECT check_no, check_status FROM bill_payments WHERE id = $1", [payId]);
    expect(Number(row.rows[0].check_no)).toBe(1001);
    expect(row.rows[0].check_status).toBe("manual");
  });

  it("method='electronic' never carries check fields", async () => {
    const vend = await createVendor(c, db.companyA, "Electronic Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A, method: "electronic",
    });
    const row = await c.query(
      "SELECT check_no, check_status FROM bill_payments WHERE id = $1", [payId]);
    expect(row.rows[0].check_no).toBeNull();
    expect(row.rows[0].check_status).toBeNull();
  });

  it("rejects a check_no on an electronic payment", async () => {
    const vend = await createVendor(c, db.companyA, "Bad Electronic Vendor");
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A,
        method: "electronic", checkNo: 5,
      }),
    ).rejects.toThrow(/check number can only be set for check payments/);
  });

  it("manual check numbers are unique per (company, money account), not globally", async () => {
    const vend = await createVendor(c, db.companyA, "Dup Check Vendor");
    const account = await createMoneyAccount(db.companyA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "check", checkNo: 2001,
    });
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "20.0000", moneyAccountId: account, method: "check", checkNo: 2001,
      }),
    ).rejects.toThrow(/duplicate key|unique/);

    // a DIFFERENT account (even within the same company) is a separate
    // number line — same number, no conflict
    const account2 = await createMoneyAccount(db.companyA);
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "10.0000", moneyAccountId: account2, method: "check", checkNo: 2001,
      }),
    ).resolves.toBeGreaterThan(0);

    // company B is a different tenant entirely — same number, no conflict
    const cB = await db.appClient(db.companyB);
    const vendB = await createVendor(cB, db.companyB, "Dup Check Vendor B");
    await expect(
      payBills(cB, db.companyB, db.userB, {
        vendorId: vendB, amount: "10.0000", moneyAccountId: db.accounts["1000"].B, method: "check", checkNo: 2001,
      }),
    ).resolves.toBeGreaterThan(0);
  });
});

describe("assign_check_numbers", () => {
  it("assigns sequential numbers in (payment_date, id) order and marks printed", async () => {
    const vend = await createVendor(c, db.companyA, "Print Run Vendor");
    const account = await createMoneyAccount(db.companyA);
    const pay2 = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "check", paymentDate: "2024-06-02",
    });
    const pay1 = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "20.0000", moneyAccountId: account, method: "check", paymentDate: "2024-06-01",
    });
    await c.query("SELECT set_next_check_no($1, $2, 500, $3)", [db.companyA, account, db.userA]);

    const res = await c.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4) ORDER BY check_no",
      [db.companyA, account, [pay1, pay2], db.userA]);
    expect(res.rows.map((r) => Number(r.check_no))).toEqual([500, 501]);
    // pay1 (dated 2024-06-01) must get the lower number, pay2 the next
    const byPayment = new Map(res.rows.map((r) => [Number(r.payment_id), Number(r.check_no)]));
    expect(byPayment.get(pay1)).toBe(500);
    expect(byPayment.get(pay2)).toBe(501);

    const rows = await c.query(
      "SELECT id, check_no, check_status, printed_on FROM bill_payments WHERE id = ANY($1::bigint[])",
      [[pay1, pay2]]);
    for (const r of rows.rows) {
      expect(r.check_status).toBe("printed");
      expect(r.printed_on).not.toBeNull();
    }
  });

  it("refuses a payment that is not a check queued to print on the requested account", async () => {
    const vend = await createVendor(c, db.companyA, "Not Queued Vendor");
    const account = await createMoneyAccount(db.companyA);
    const electronicPay = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "electronic",
    });
    await expect(
      c.query("SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)",
        [db.companyA, account, [electronicPay], db.userA]),
    ).rejects.toThrow(/not a check payment queued to print/);

    const manualPay = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "check", checkNo: 9001,
    });
    await expect(
      c.query("SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)",
        [db.companyA, account, [manualPay], db.userA]),
    ).rejects.toThrow(/not a check payment queued to print/);

    await expect(
      c.query("SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)",
        [db.companyA, account, [999999], db.userA]),
    ).rejects.toThrow(/payment 999999 not found/);
  });

  it("concurrent print runs on the SAME account never produce duplicate check numbers", async () => {
    const vend = await createVendor(c, db.companyA, "Race Check Vendor");
    const account = await createMoneyAccount(db.companyA);
    const payA = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "check", paymentDate: "2024-07-01",
    });
    const payB = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "20.0000", moneyAccountId: account, method: "check", paymentDate: "2024-07-02",
    });

    const a = await db.appClient(db.companyA);
    const b = await db.appClient(db.companyA);
    await a.query("BEGIN");
    const resA = await a.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)", [db.companyA, account, [payA], db.userA]);
    const pendingB = inFlight(b.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)", [db.companyA, account, [payB], db.userA]));
    await new Promise((r) => setTimeout(r, 300));
    // B must still be waiting on A's check-counter row. If it isn't, the two
    // print runs did not serialize and the numbers below agree only by luck.
    pendingB.stillBlocked("B's assign_check_numbers");
    await a.query("COMMIT");
    const resB = await pendingB.settled();

    const noA = Number(resA.rows[0].check_no);
    const noB = Number(resB.rows[0].check_no);
    expect(noA).not.toBe(noB); // no duplicate numbers
    expect(noB).toBe(noA + 1); // B serialized behind A, picked up the next number
  });
});

describe("set_next_check_no", () => {
  it("refuses a value that does not exceed the highest check_no already used on that account", async () => {
    const vend = await createVendor(c, db.companyA, "Set Counter Too Low Vendor");
    const account = await createMoneyAccount(db.companyA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "10.0000", moneyAccountId: account, method: "check", checkNo: 300,
    });
    await expect(
      c.query("SELECT set_next_check_no($1, $2, 300, $3)", [db.companyA, account, db.userA]),
    ).rejects.toThrow(/next check number must be greater than the highest used/);
    await expect(
      c.query("SELECT set_next_check_no($1, $2, 299, $3)", [db.companyA, account, db.userA]),
    ).rejects.toThrow(/next check number must be greater than the highest used/);
  });

  it("fast-forwards the counter, and the next assigned number picks it up", async () => {
    const vend = await createVendor(c, db.companyA, "Set Counter OK Vendor");
    const account = await createMoneyAccount(db.companyA);
    await c.query("SELECT set_next_check_no($1, $2, 700, $3)", [db.companyA, account, db.userA]);
    const counterRow = await c.query(
      "SELECT next_check_no FROM check_counters WHERE company_id = $1 AND money_account_id = $2",
      [db.companyA, account]);
    expect(Number(counterRow.rows[0].next_check_no)).toBe(700);

    const pay = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "20.0000", moneyAccountId: account, method: "check",
    });
    const res = await c.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)", [db.companyA, account, [pay], db.userA]);
    expect(Number(res.rows[0].check_no)).toBe(700);
  });

  it("a different money account on the same company has its own independent counter", async () => {
    const cashAccount = await createMoneyAccount(db.companyA);
    const dueFromAccount = await createMoneyAccount(db.companyA); // untouched, distinct asset account
    await c.query("SELECT set_next_check_no($1, $2, 900, $3)", [db.companyA, cashAccount, db.userA]);
    const other = await c.query(
      "SELECT next_check_no FROM check_counters WHERE company_id = $1 AND money_account_id = $2",
      [db.companyA, dueFromAccount]);
    expect(other.rows.length).toBe(0); // untouched — no row for the other account yet
  });

  // db/migrations/0010_counter_lock_ordering.sql — TOCTOU fix (owner review,
  // shared with AR's set_next_invoice_no; mirrors the identical AR test
  // above in tests/invariants/ar.test.ts). set_next_check_no() used to read
  // max(check_no) BEFORE locking check_counters, so a concurrent
  // assign_check_numbers() (which locks that row FIRST) could commit a
  // brand-new check number in the gap, leaving the setter validating
  // against a now-stale max. Reproduced with real overlapping transactions,
  // same pattern as the "concurrent print runs" race test above: A holds
  // assign_check_numbers() open (uncommitted) while B calls
  // set_next_check_no for the EXACT number A is about to commit — B must
  // block on the counter row's lock, not race past it, so once unblocked it
  // validates against the TRUE (post-commit) max and correctly refuses,
  // rather than silently succeeding against the stale pre-commit max.
  it("set_next_check_no serializes against a concurrent assign_check_numbers — no stale fast-forward", async () => {
    const vend = await createVendor(c, db.companyA, "Check Counter Race Vendor");
    const account = await createMoneyAccount(db.companyA);
    const pay = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "15.0000", moneyAccountId: account, method: "check", // queued to_print
    });

    const a = await db.appClient(db.companyA); // assigns the check number, held open
    const b = await db.appClient(db.companyA); // races set_next_check_no

    await a.query("BEGIN");
    const assignRes = await a.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)", [db.companyA, account, [pay], db.userA]);
    const assignedNo = Number(assignRes.rows[0].check_no);

    // B tries to fast-forward to EXACTLY the number A just (uncommitted-ly)
    // assigned — must serialize behind A's counter-row lock and, once
    // unblocked, see the true max (including A's now-committed check).
    const pending = inFlight(b.query(
      "SELECT set_next_check_no($1, $2, $3, $4)", [db.companyA, account, assignedNo, db.userA]));
    await new Promise((r) => setTimeout(r, 300));
    // Still blocked on the counter row A locked first — that IS the fix from
    // migration 0010. A B that has already finished would have validated
    // against the stale pre-commit max, which is the bug.
    pending.stillBlocked("B's set_next_check_no");
    await a.query("COMMIT");
    await expect(pending.settled())
      .rejects.toThrow(/next check number must be greater than the highest used/);

    // The counter is left exactly where assign_check_numbers() put it — B's
    // failed attempt changed nothing.
    const counterRow = await c.query(
      "SELECT next_check_no FROM check_counters WHERE company_id = $1 AND money_account_id = $2",
      [db.companyA, account]);
    expect(Number(counterRow.rows[0].next_check_no)).toBe(assignedNo + 1);

    // A subsequent assignment succeeds cleanly at the very next number — the
    // race left the counter fully consistent, not primed to collide with (or
    // waste) a number, which is the actual harm this fix prevents (the
    // eventual duplicate was already impossible; an AVOIDABLE later failure
    // was the real bug).
    const pay2 = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "25.0000", moneyAccountId: account, method: "check",
    });
    const res2 = await c.query(
      "SELECT * FROM assign_check_numbers($1, $2, $3::bigint[], $4)", [db.companyA, account, [pay2], db.userA]);
    expect(Number(res2.rows[0].check_no)).toBe(assignedNo + 1);
  });
});

describe("over-application is rejected at commit (deferred constraint triggers)", () => {
  it("rejects applying more than the payment's own amount", async () => {
    const vend = await createVendor(c, db.companyA, "Payment Overapply Vendor");
    const bill1 = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "1000.0000", amount: "1000.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill1, db.userA);
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "50.0000", moneyAccountId: db.accounts["1000"].A,
        applications: [{ billId: bill1, amount: "999.0000" }],
      }),
    ).rejects.toThrow(/over-applied/);
  });

  it("rejects applying more than a bill's own total across two payments", async () => {
    const vend = await createVendor(c, db.companyA, "Bill Overapply Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "60.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "60.0000" }],
    });
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "60.0000", moneyAccountId: db.accounts["1000"].A,
        applications: [{ billId: bill, amount: "60.0000" }], // 60 + 60 > 100
      }),
    ).rejects.toThrow(/over-applied/);
  });
});

describe("bill_payment_applications are immutable", () => {
  it("no UPDATE or DELETE, not even by the superuser", async () => {
    const vend = await createVendor(c, db.companyA, "Immutable App Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "40.0000", amount: "40.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "40.0000" }],
    });
    await expect(
      c.query("UPDATE bill_payment_applications SET amount = '1.0000' WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM bill_payment_applications WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
    await expect(
      db.admin.query("UPDATE bill_payment_applications SET amount = '1.0000' WHERE bill_id = $1", [bill]),
    ).rejects.toThrow(/immutable/);
  });
});

describe("void_bill_payment", () => {
  it("reverses the entry and its applications no longer count toward bill open balance", async () => {
    const vend = await createVendor(c, db.companyA, "Void Payment Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "150.0000", amount: "150.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "150.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "150.0000" }],
    });
    expect(await openBalance(c, db.companyA, bill)).toBe("0.0000");

    await c.query("SELECT void_bill_payment($1, $2, '2024-05-25', $3)", [db.companyA, payId, db.userA]);

    const row = await c.query("SELECT status, void_entry_id FROM bill_payments WHERE id = $1", [payId]);
    expect(row.rows[0].status).toBe("void");
    expect(Number(row.rows[0].void_entry_id)).toBeGreaterThan(0);
    expect(await openBalance(c, db.companyA, bill)).toBe("150.0000");
  });

  it("sets check_status to 'void' (and never reuses the number) when the payment was a check", async () => {
    const vend = await createVendor(c, db.companyA, "Void Check Vendor");
    const account = await createMoneyAccount(db.companyA);
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "20.0000", moneyAccountId: account, method: "check", checkNo: 4001,
    });
    await c.query("SELECT void_bill_payment($1, $2, '2024-05-25', $3)", [db.companyA, payId, db.userA]);
    const row = await c.query("SELECT check_no, check_status FROM bill_payments WHERE id = $1", [payId]);
    expect(Number(row.rows[0].check_no)).toBe(4001);
    expect(row.rows[0].check_status).toBe("void");

    // the number stays consumed — a NEW check on the same account cannot reuse it
    await expect(
      payBills(c, db.companyA, db.userA, {
        vendorId: vend, amount: "5.0000", moneyAccountId: account, method: "check", checkNo: 4001,
      }),
    ).rejects.toThrow(/duplicate key|unique/);
  });

  it("cannot void an already-void payment", async () => {
    const vend = await createVendor(c, db.companyA, "Double Void Payment Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "20.0000", moneyAccountId: db.accounts["1000"].A,
    });
    await c.query("SELECT void_bill_payment($1, $2, '2024-05-25', $3)", [db.companyA, payId, db.userA]);
    await expect(
      c.query("SELECT void_bill_payment($1, $2, '2024-05-26', $3)", [db.companyA, payId, db.userA]),
    ).rejects.toThrow(/already void/);
  });
});

describe("apply_vendor_credit", () => {
  it("applies an existing payment's unapplied remainder to another bill", async () => {
    const vend = await createVendor(c, db.companyA, "Apply Credit Vendor");
    const bill1 = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    const bill2 = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "30.0000", amount: "30.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill1, db.userA);
    await issueBill(c, db.companyA, bill2, db.userA);
    // overpay bill 1: pay 80 but only apply 50, leaving 30 unapplied
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "80.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill1, amount: "50.0000" }],
    });
    expect(await openBalance(c, db.companyA, bill2)).toBe("30.0000");

    await applyVendorCredit(c, db.companyA, payId, [{ billId: bill2, amount: "30.0000" }], db.userA);

    expect(await openBalance(c, db.companyA, bill2)).toBe("0.0000");
    expect(await unappliedBalance(c, db.companyA, payId)).toBe("0.0000");
  });
});

describe("cross-company impossibility (CLAUDE.md §4)", () => {
  it("a bill_payment_application cannot reference another company's bill (composite FK)", async () => {
    const vendA = await createVendor(c, db.companyA, "FK Test Vendor A");
    const billA = await draftBill(c, db.companyA, db.userA, vendA, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, billA, db.userA);
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vendA, amount: "10.0000", moneyAccountId: db.accounts["1000"].A,
    });
    await expect(
      c.query(
        `INSERT INTO bill_payment_applications (company_id, payment_id, bill_id, amount)
         VALUES ($1, $2, 999999, '5.0000')`,
        [db.companyA, payId]),
    ).rejects.toThrow(/permission denied/);
  });

  it("a bill cannot reference another company's vendor (composite FK)", async () => {
    const vendB = await createVendor(await db.appClient(db.companyB), db.companyB, "Vendor Only In B");
    await expect(
      draftBill(c, db.companyA, db.userA, vendB, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
      }),
    ).rejects.toThrow(/foreign key|violates/);
  });

  it("a bill line cannot reference another company's category account (composite FK)", async () => {
    const vend = await createVendor(c, db.companyA, "FK Line Test Vendor");
    await expect(
      draftBill(c, db.companyA, db.userA, vend, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].B }],
      }),
    ).rejects.toThrow(/not found|foreign key|violates/);
  });
});

describe("RLS isolation for AP tables (CLAUDE.md §3.5, §4)", () => {
  it("with no app.company_id set, every AP table returns zero rows", async () => {
    await createVendor(c, db.companyA, "Isolation Fixture Vendor");
    const anon = await db.appClient(null);
    for (const table of ["vendors", "bills", "bill_lines", "bill_payments", "bill_payment_applications"]) {
      const res = await anon.query(`SELECT count(*)::int AS n FROM ${table}`);
      expect(res.rows[0].n).toBe(0);
    }
  });

  it("company B cannot see company A's vendors or bills", async () => {
    const vend = await createVendor(c, db.companyA, "A-Only Vendor");
    await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    const cB = await db.appClient(db.companyB);
    const vendors = await cB.query("SELECT * FROM vendors WHERE name = 'A-Only Vendor'");
    expect(vendors.rows.length).toBe(0);
  });

  it("cannot insert a vendor row for another company (RLS WITH CHECK)", async () => {
    await expect(
      c.query(
        `INSERT INTO vendors (company_id, name) VALUES ($1, 'Sneaky Vendor')`,
        [db.companyB]),
    ).rejects.toThrow(/row-level security/);
  });
});

describe("bill_payments grants let the immutability trigger speak English", () => {
  it("an app UPDATE on a payment is refused in English, not a bare permission error", async () => {
    const vend = await createVendor(c, db.companyA, "Payment Grant Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "25.0000", moneyAccountId: db.accounts["1000"].A,
    });
    await expect(
      c.query("UPDATE bill_payments SET memo = 'edited' WHERE id = $1", [payId]),
    ).rejects.toThrow(/immutable|voided through/);
    await expect(
      c.query("DELETE FROM bill_payments WHERE id = $1", [payId]),
    ).rejects.toThrow(/immutable|voided through/);
  });
});

describe("bill_payment_unapplied_balance reports 0 for a voided payment", () => {
  it("a voided payment's unapplied balance is 0.0000, not its stale credit amount", async () => {
    const vend = await createVendor(c, db.companyA, "Voided Credit Vendor");
    const payId = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "35.0000", moneyAccountId: db.accounts["1000"].A,
    });
    expect(await unappliedBalance(c, db.companyA, payId)).toBe("35.0000");

    await c.query("SELECT void_bill_payment($1, $2, '2024-05-25', $3)", [db.companyA, payId, db.userA]);

    expect(await unappliedBalance(c, db.companyA, payId)).toBe("0.0000");
  });
});

describe("derived open balances cross-checked in SQL", () => {
  it("bill_open_balance matches a from-scratch SQL computation", async () => {
    const vend = await createVendor(c, db.companyA, "Cross-Check Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [
        { unitPrice: "70.0000", amount: "70.0000", categoryAccountId: db.accounts["6000"].A },
        { unitPrice: "30.0000", amount: "30.0000", categoryAccountId: db.accounts["6000"].A },
      ],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "40.0000", moneyAccountId: db.accounts["1000"].A,
      applications: [{ billId: bill, amount: "40.0000" }],
    });

    const fromFunction = await openBalance(c, db.companyA, bill);
    const fromScratch = await c.query(
      `SELECT (
         (SELECT coalesce(sum(amount), 0) FROM bill_lines WHERE bill_id = $1)
         - (SELECT coalesce(sum(bpa.amount), 0) FROM bill_payment_applications bpa
              JOIN bill_payments p ON p.id = bpa.payment_id
            WHERE bpa.bill_id = $1 AND p.status = 'active')
       )::numeric(19,4) AS bal`,
      [bill]);
    expect(fromFunction).toBe(fromScratch.rows[0].bal);
    expect(fromFunction).toBe("60.0000");
  });
});

// ---- concurrency races (mirrors AR 0006 FIX 1) ---------------------------

describe("concurrent applications cannot over-apply a bill", () => {
  it("two concurrent apply_vendor_credit calls applying 60+60 to a 100 bill: exactly one succeeds", async () => {
    const vend = await createVendor(c, db.companyA, "Race Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    const payA = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "60.0000", moneyAccountId: db.accounts["1000"].A,
    });
    const payB = await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "60.0000", moneyAccountId: db.accounts["1000"].A,
    });

    const a = await db.appClient(db.companyA);
    const b = await db.appClient(db.companyA);
    await a.query("BEGIN");
    await applyVendorCredit(a, db.companyA, payA, [{ billId: bill, amount: "60.0000" }], db.userA);
    const pending = inFlight(
      applyVendorCredit(b, db.companyA, payB, [{ billId: bill, amount: "60.0000" }], db.userA));
    await new Promise((r) => setTimeout(r, 300));
    // Blocked on the bill's row lock. If B had got through already, the
    // "exactly one succeeds" result below would be an accident of timing.
    pending.stillBlocked("B's apply_vendor_credit");
    await a.query("COMMIT");
    await expect(pending.settled()).rejects.toThrow(/over-applied/);

    expect(await openBalance(c, db.companyA, bill)).toBe("40.0000");
  });

  it("the same race also cannot slip past pay_bills applying to an existing bill", async () => {
    const vend = await createVendor(c, db.companyA, "Race Vendor 2");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);

    const a = await db.appClient(db.companyA);
    const b = await db.appClient(db.companyA);
    const specFor = (amount: string) => JSON.stringify({
      vendor_id: vend, payment_date: "2024-05-15", amount,
      money_account_id: db.accounts["1000"].A, method: "electronic", memo: null,
      applications: [{ bill_id: bill, amount }],
    });
    await a.query("BEGIN");
    await a.query("SELECT pay_bills($1, $2::jsonb, $3)", [db.companyA, specFor("60.0000"), db.userA]);
    const pending = inFlight(b.query(
      "SELECT pay_bills($1, $2::jsonb, $3)", [db.companyA, specFor("60.0000"), db.userA]));
    await new Promise((r) => setTimeout(r, 300));
    pending.stillBlocked("B's pay_bills");
    await a.query("COMMIT");
    await expect(pending.settled()).rejects.toThrow(/over-applied/);

    expect(await openBalance(c, db.companyA, bill)).toBe("40.0000");
  });
});

// ---- ensure_ap_account (mirrors AR 0006 FIX 2) ---------------------------

describe("ensure_ap_account requires exactly one active match", () => {
  it("raises a loud English error when no 'Accounts Payable' account exists", async () => {
    const scratch = await createScratchCompany("No AP Account Co", []);
    const cScratch = await db.appClient(scratch.companyId);
    const vend = await createVendor(cScratch, scratch.companyId, "Some Vendor");
    const bill = await draftBill(cScratch, scratch.companyId, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: scratch.expenseAccountId }],
    });
    await expect(issueBill(cScratch, scratch.companyId, bill, db.userA)).rejects.toThrow(
      /No account named 'Accounts Payable' exists — create one or set it in Settings/);
  });

  it("raises a loud English error when more than one active 'Accounts Payable' account exists", async () => {
    const scratch = await createScratchCompany("Ambiguous AP Co", [
      { name: "Accounts Payable" }, { name: "Accounts Payable" },
    ]);
    const cScratch = await db.appClient(scratch.companyId);
    const vend = await createVendor(cScratch, scratch.companyId, "Some Vendor");
    const bill = await draftBill(cScratch, scratch.companyId, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: scratch.expenseAccountId }],
    });
    await expect(issueBill(cScratch, scratch.companyId, bill, db.userA)).rejects.toThrow(
      /More than one active account is named 'Accounts Payable' — set the one to use in Settings/);
  });

  it("ignores an inactive 'Accounts Payable' account and uses the one active match", async () => {
    const scratch = await createScratchCompany("Inactive AP Co", [
      { name: "Accounts Payable", active: false }, { name: "Accounts Payable", active: true },
    ]);
    const cScratch = await db.appClient(scratch.companyId);
    const vend = await createVendor(cScratch, scratch.companyId, "Some Vendor");
    const bill = await draftBill(cScratch, scratch.companyId, db.userA, vend, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: scratch.expenseAccountId }],
    });
    await expect(issueBill(cScratch, scratch.companyId, bill, db.userA)).resolves.toBeUndefined();
  });
});

// ---- ap_aging (mirrors AR 0007 point-in-time semantics) ------------------

describe("ap_aging", () => {
  it("buckets bills correctly and includes document_only bills and unapplied credit", async () => {
    const vend = await createVendor(c, db.companyA, "Aging Vendor");
    const asOf = "2024-08-01";

    // current: due in the future
    const billCurrent = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-07-25", dueDate: "2024-08-15",
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    // 1-30 past due: due 10 days ago
    const bill1to30 = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-07-01", dueDate: "2024-07-22",
      lines: [{ unitPrice: "200.0000", amount: "200.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    // 31-60 past due: due 45 days ago
    const bill31to60 = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-06-01", dueDate: "2024-06-17",
      lines: [{ unitPrice: "300.0000", amount: "300.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    // 61-90 past due: due 75 days ago
    const bill61to90 = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-05-01", dueDate: "2024-05-18",
      lines: [{ unitPrice: "400.0000", amount: "400.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    // 90+ past due: due 200 days ago
    const bill90plus = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-01-01", dueDate: "2024-01-14",
      lines: [{ unitPrice: "500.0000", amount: "500.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    // document_only, past due (47 days) — should still appear, bucket 31-60
    const billDocOnly = await draftBill(c, db.companyA, db.userA, vend, {
      documentOnly: true, billDate: "2024-05-16", dueDate: "2024-06-15",
      lines: [{ unitPrice: "2789.80", amount: "2789.80", categoryAccountId: db.accounts["6000"].A }],
    });
    // voided bill — should NOT appear
    const billVoided = await draftBill(c, db.companyA, db.userA, vend, {
      dueDate: "2024-07-01",
      lines: [{ unitPrice: "999.0000", amount: "999.0000", categoryAccountId: db.accounts["6000"].A }],
    });

    for (const bill of [billCurrent, bill1to30, bill31to60, bill61to90, bill90plus, billDocOnly, billVoided]) {
      await issueBill(c, db.companyA, bill, db.userA);
    }
    await c.query("SELECT void_bill($1, $2, $3, $4)", [db.companyA, billVoided, asOf, db.userA]);

    // an unapplied credit for this vendor
    await payBills(c, db.companyA, db.userA, {
      vendorId: vend, amount: "77.0000", moneyAccountId: db.accounts["1000"].A,
    });

    const res = await c.query("SELECT * FROM ap_aging($1, $2)", [db.companyA, asOf]);
    const byBill = new Map(
      res.rows.filter((r) => r.row_type === "bill").map((r) => [Number(r.bill_id), r]));

    expect(byBill.get(billCurrent)?.bucket).toBe("current");
    expect(byBill.get(bill1to30)?.bucket).toBe("1-30");
    expect(byBill.get(bill31to60)?.bucket).toBe("31-60");
    expect(byBill.get(bill61to90)?.bucket).toBe("61-90");
    expect(byBill.get(bill90plus)?.bucket).toBe("90+");
    expect(byBill.get(billDocOnly)?.bucket).toBe("31-60");
    expect(byBill.get(billDocOnly)?.document_only).toBe(true);
    expect(byBill.get(billDocOnly)?.open_amount).toBe("2789.8000");
    expect(byBill.has(billVoided)).toBe(false);

    const creditRow = res.rows.find((r) => r.row_type === "credit" && Number(r.vendor_id) === vend);
    expect(creditRow).toBeTruthy();
    expect(creditRow.open_amount).toBe("77.0000");
  });

  it("is genuinely point-in-time: a bill issued after as_of doesn't appear, and a later void doesn't retroactively remove it", async () => {
    const vend = await createVendor(c, db.companyA, "Point In Time Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-09-01", dueDate: "2024-09-30",
      lines: [{ unitPrice: "150.0000", amount: "150.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);

    // as_of BEFORE the bill's own date: it must not appear at all yet
    const before = await c.query("SELECT * FROM ap_aging($1, '2024-08-01')", [db.companyA]);
    expect(before.rows.some((r) => Number(r.bill_id) === bill)).toBe(false);

    // as_of on/after the bill date: appears
    const onDate = await c.query("SELECT * FROM ap_aging($1, '2024-09-01')", [db.companyA]);
    expect(onDate.rows.some((r) => Number(r.bill_id) === bill)).toBe(true);

    // void it today (2024-10-01) — a report dated BEFORE the void must still show it
    await c.query("SELECT void_bill($1, $2, '2024-10-01', $3)", [db.companyA, bill, db.userA]);
    const beforeVoid = await c.query("SELECT * FROM ap_aging($1, '2024-09-15')", [db.companyA]);
    expect(beforeVoid.rows.some((r) => Number(r.bill_id) === bill)).toBe(true);

    // a report dated on/after the void must not
    const afterVoid = await c.query("SELECT * FROM ap_aging($1, '2024-10-01')", [db.companyA]);
    expect(afterVoid.rows.some((r) => Number(r.bill_id) === bill)).toBe(false);
  });
});

// ---- period locks cover document_only bills too (mirrors AR 0008 DECISION 1) --

describe("period locks cover document_only bills too", () => {
  it("refuses to issue a document_only bill dated inside a locked period, with the same English-mapped text post_entry uses", async () => {
    const vend = await createVendor(c, db.companyA, "Locked Doc Issue Vendor");
    await c.query(
      `INSERT INTO period_locks (company_id, locked_through, created_by)
       VALUES ($1, '2024-06-30', $2)`, [db.companyA, db.userA]);
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      documentOnly: true, billDate: "2024-06-15", dueDate: "2024-07-15",
      lines: [{ unitPrice: "100.0000", amount: "100.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await expect(issueBill(c, db.companyA, bill, db.userA))
      .rejects.toThrow(/cannot post into locked period \(locked through/);
  });

  it("refuses to void a document_only bill with a void date inside the locked period", async () => {
    const vend = await createVendor(c, db.companyA, "Locked Doc Void Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      // dated AFTER the 2024-06-30 lock, so issuing succeeds — this test is
      // specifically about the VOID date, not the bill date.
      documentOnly: true, billDate: "2024-07-05", dueDate: "2024-08-04",
      lines: [{ unitPrice: "200.0000", amount: "200.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      c.query("SELECT void_bill($1, $2, '2024-06-20', $3)", [db.companyA, bill, db.userA]),
    ).rejects.toThrow(/cannot post into locked period \(locked through/);
  });

  it("refuses to void a document_only bill with a void date before its own bill date", async () => {
    const vend = await createVendor(c, db.companyA, "Doc Void Before Issue Vendor");
    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      documentOnly: true, billDate: "2024-07-10", dueDate: "2024-08-09",
      lines: [{ unitPrice: "75.0000", amount: "75.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await expect(
      c.query("SELECT void_bill($1, $2, '2024-07-05', $3)", [db.companyA, bill, db.userA]),
    ).rejects.toThrow(/reversal date cannot precede the entry it reverses/);
  });

  it("normal (non-document_only) bills are unaffected: post_entry/reverse_entry still own these checks", async () => {
    const vend = await createVendor(c, db.companyA, "Normal Unaffected Vendor");

    const lockedDraft = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-06-15", dueDate: "2024-07-15",
      lines: [{ unitPrice: "10.0000", amount: "10.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await expect(issueBill(c, db.companyA, lockedDraft, db.userA))
      .rejects.toThrow(/cannot post into locked period/);

    const bill = await draftBill(c, db.companyA, db.userA, vend, {
      billDate: "2024-07-20", dueDate: "2024-08-19",
      lines: [{ unitPrice: "400.0000", amount: "400.0000", categoryAccountId: db.accounts["6000"].A }],
    });
    await issueBill(c, db.companyA, bill, db.userA);
    await c.query("SELECT void_bill($1, $2, '2024-08-01', $3)", [db.companyA, bill, db.userA]);
    const row = await c.query("SELECT status, void_entry_id FROM bills WHERE id = $1", [bill]);
    expect(row.rows[0].status).toBe("void");
    expect(Number(row.rows[0].void_entry_id)).toBeGreaterThan(0);
  });
});
