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 createCustomer(
  client: Client, companyId: string, name: string, termsDays = 30,
): Promise<number> {
  const r = await client.query(
    `INSERT INTO customers (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; incomeAccountId: number }

async function draftInvoice(
  client: Client, companyId: string, userId: string, customerId: number,
  opts: { invoiceDate?: string; dueDate?: string; memo?: string; documentOnly?: boolean; lines: LineSpec[] },
): Promise<number> {
  const invoiceDate = opts.invoiceDate ?? "2024-05-01";
  const dueDate = opts.dueDate ?? "2024-05-31";
  const r = await client.query(
    `INSERT INTO invoices (company_id, customer_id, invoice_date, due_date, memo, document_only, created_by)
     VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
    [companyId, customerId, invoiceDate, dueDate, opts.memo ?? null, opts.documentOnly ?? false, userId]);
  const invoiceId = Number(r.rows[0].id);
  for (const l of opts.lines) {
    await client.query(
      `INSERT INTO invoice_lines
         (company_id, invoice_id, description, quantity, unit_price, amount, income_account_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7)`,
      [companyId, invoiceId, l.description ?? "item", l.quantity ?? "1", l.unitPrice, l.amount, l.incomeAccountId]);
  }
  return invoiceId;
}

async function issueInvoice(client: Client, companyId: string, invoiceId: number, userId: string): Promise<number> {
  const r = await client.query(
    "SELECT issue_invoice($1, $2, $3) AS invoice_no", [companyId, invoiceId, userId]);
  return Number(r.rows[0].invoice_no);
}

interface AppSpec { invoiceId: number; amount: string }
async function receivePayment(
  client: Client, companyId: string, userId: string,
  spec: { customerId: number; paymentDate?: string; amount: string; depositAccountId: number; memo?: string; applications?: AppSpec[] },
): Promise<number> {
  const jsonSpec = {
    customer_id: spec.customerId,
    payment_date: spec.paymentDate ?? "2024-05-15",
    amount: spec.amount,
    deposit_account_id: spec.depositAccountId,
    memo: spec.memo ?? null,
    applications: (spec.applications ?? []).map((a) => ({ invoice_id: a.invoiceId, amount: a.amount })),
  };
  const r = await client.query(
    "SELECT receive_payment($1, $2::jsonb, $3) AS id", [companyId, JSON.stringify(jsonSpec), userId]);
  return Number(r.rows[0].id);
}

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

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

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

/** A fresh company + chart, built directly via the admin (superuser) client so
 * tests can control exactly which accounts exist — needed for the
 * ensure_ar_account() ambiguity/absence tests, since companyA/companyB's
 * shared chart already resolves and caches an AR account in company_settings
 * well before those tests run. */
async function createScratchCompany(
  name: string, arAccounts: Array<{ name: string; active?: boolean }>,
): Promise<{ companyId: string; revenueAccountId: 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 arAccounts) {
    await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type, active)
       VALUES ($1, $2, $3, 'asset', $4)`,
      [companyId, "AR-" + 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 rev = await db.admin.query(
    `INSERT INTO accounts (company_id, code, name, type) VALUES ($1, '4000', 'Revenue', 'income') RETURNING id`,
    [companyId]);
  return {
    companyId,
    revenueAccountId: Number(rev.rows[0].id),
    cashAccountId: Number(cash.rows[0].id),
  };
}

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

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

describe("invoice_lines income-account enforcement", () => {
  it("rejects a line posting to a non-income account", async () => {
    const cust = await createCustomer(c, db.companyA, "Bad Account Customer");
    await expect(
      draftInvoice(c, db.companyA, db.userA, cust, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["1000"].A }],
      }),
    ).rejects.toThrow(/income account/);
  });
});

describe("company_settings AR account", () => {
  it("auto-detects the 'Accounts Receivable' asset account on first use", async () => {
    const cust = await createCustomer(c, db.companyA, "Auto Detect Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    const r = await c.query("SELECT ar_account_id FROM company_settings WHERE company_id = $1", [db.companyA]);
    expect(Number(r.rows[0].ar_account_id)).toBe(db.accounts["1100"].A);
  });

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

describe("issue_invoice", () => {
  it("refuses to issue a draft with no lines", async () => {
    const cust = await createCustomer(c, db.companyA, "No Lines Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, { lines: [] });
    await expect(issueInvoice(c, db.companyA, inv, db.userA)).rejects.toThrow(/no lines/);
  });

  it("posts a balanced entry: debit AR total, credit each line's income account", async () => {
    const cust = await createCustomer(c, db.companyA, "Balanced Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [
        { unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A },
        { unitPrice: "50.0000", amount: "50.0000", incomeAccountId: db.accounts["4000"].A },
      ],
    });
    const no = await issueInvoice(c, db.companyA, inv, db.userA);
    expect(no).toBeGreaterThan(0);

    const row = await c.query(
      "SELECT status, invoice_no, posted_entry_id FROM invoices WHERE id = $1", [inv]);
    expect(row.rows[0].status).toBe("open");
    expect(Number(row.rows[0].invoice_no)).toBe(no);
    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 debit DESC`,
      [entryId]);
    expect(lines.rows).toEqual([
      { account_id: String(db.accounts["1100"].A), debit: "150.0000", credit: "0.0000" },
      { account_id: String(db.accounts["4000"].A), debit: "0.0000", credit: "150.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 income account into one credit line", async () => {
    const cust = await createCustomer(c, db.companyA, "Grouping Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [
        { unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A },
        { unitPrice: "20.0000", amount: "20.0000", incomeAccountId: db.accounts["4000"].A },
      ],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    const row = await c.query("SELECT posted_entry_id FROM invoices WHERE id = $1", [inv]);
    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 AR debit line + one grouped credit line, not two credits
  });

  it("cannot issue an already-issued invoice", async () => {
    const cust = await createCustomer(c, db.companyA, "Double Issue Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(issueInvoice(c, db.companyA, inv, db.userA)).rejects.toThrow(/already/);
  });

  it("document_only invoices get a number but post NO ledger entry", async () => {
    const cust = await createCustomer(c, db.companyA, "History Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      documentOnly: true,
      lines: [{ unitPrice: "2789.80", amount: "2789.80", incomeAccountId: db.accounts["4000"].A }],
    });
    const no = await issueInvoice(c, db.companyA, inv, db.userA);
    expect(no).toBeGreaterThan(0);
    const row = await c.query(
      "SELECT status, invoice_no, posted_entry_id FROM invoices WHERE id = $1", [inv]);
    expect(row.rows[0].status).toBe("open");
    expect(Number(row.rows[0].invoice_no)).toBe(no);
    expect(row.rows[0].posted_entry_id).toBeNull();
  });

  it("invoice numbers are per-company, starting at 1", async () => {
    const cB = await db.appClient(db.companyB);
    const cust = await createCustomer(cB, db.companyB, "Fresh Co B");
    const inv = await draftInvoice(cB, db.companyB, db.userB, cust, {
      lines: [{ unitPrice: "5.0000", amount: "5.0000", incomeAccountId: db.accounts["4000"].B }],
    });
    const no = await issueInvoice(cB, db.companyB, inv, db.userB);
    expect(no).toBe(1); // company B's own counter, independent of company A's
  });
});

describe("issued-invoice immutability (mirrors CLAUDE.md §3.2)", () => {
  it("header fields are frozen once issued", async () => {
    const cust = await createCustomer(c, db.companyA, "Frozen Header Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      c.query("UPDATE invoices SET memo = 'edited' WHERE id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("UPDATE invoices SET due_date = '2099-01-01' WHERE id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM invoices WHERE id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
  });

  it("lines are frozen once the invoice is issued", async () => {
    const cust = await createCustomer(c, db.companyA, "Frozen Lines Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      c.query("UPDATE invoice_lines SET amount = '999.0000' WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM invoice_lines WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query(
        `INSERT INTO invoice_lines (company_id, invoice_id, unit_price, amount, income_account_id)
         VALUES ($1, $2, '1.0000', '1.0000', $3)`,
        [db.companyA, inv, db.accounts["4000"].A]),
    ).rejects.toThrow(/immutable/);
  });

  it("even the superuser cannot modify an issued invoice or its lines", async () => {
    const cust = await createCustomer(c, db.companyA, "Superuser Proof Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      db.admin.query("UPDATE invoices SET memo = 'sneaky' WHERE id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      db.admin.query("UPDATE invoice_lines SET amount = '1.0000' WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
  });

  it("drafts remain fully editable", async () => {
    const cust = await createCustomer(c, db.companyA, "Still Draft Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await c.query("UPDATE invoices SET memo = 'still editable' WHERE id = $1", [inv]);
    await c.query("UPDATE invoice_lines SET amount = '20.0000', unit_price='20.0000' WHERE invoice_id = $1", [inv]);
    await c.query("DELETE FROM invoice_lines WHERE invoice_id = $1", [inv]);
    await c.query("DELETE FROM invoices WHERE id = $1", [inv]);
  });

  it("TRUNCATE is forbidden on every AR table, even for the superuser", async () => {
    for (const table of [
      "company_settings", "customers", "invoice_counters",
      "invoices", "invoice_lines", "payments", "payment_applications",
    ]) {
      await expect(db.admin.query(`TRUNCATE ${table}`))
        .rejects.toThrow(/TRUNCATE is not allowed|foreign key/);
    }
  });
});

describe("void_invoice", () => {
  it("reverses the posted entry and restores account balances", async () => {
    const cust = await createCustomer(c, db.companyA, "Void Me Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "300.0000", amount: "300.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const before = await c.query(
      `SELECT coalesce(sum(debit-credit),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["1100"].A]);
    await issueInvoice(c, db.companyA, inv, db.userA);

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

    const row = await c.query("SELECT status, void_entry_id FROM invoices WHERE id = $1", [inv]);
    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(debit-credit),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["1100"].A]);
    expect(after.rows[0].bal).toBe(before.rows[0].bal); // restored to pre-issue balance
  });

  it("refuses to void an invoice with any payment application", async () => {
    const cust = await createCustomer(c, db.companyA, "Applied Void Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "100.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "100.0000" }],
    });
    await expect(
      c.query("SELECT void_invoice($1, $2, '2024-05-20', $3)", [db.companyA, inv, db.userA]),
    ).rejects.toThrow(/payments applied to it first/);
  });

  it("document_only void just flips status, with no reversing entry", async () => {
    const cust = await createCustomer(c, db.companyA, "History Void Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      documentOnly: true,
      lines: [{ unitPrice: "500.0000", amount: "500.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await c.query("SELECT void_invoice($1, $2, '2024-05-20', $3)", [db.companyA, inv, db.userA]);
    const row = await c.query("SELECT status, void_entry_id FROM invoices WHERE id = $1", [inv]);
    expect(row.rows[0].status).toBe("void");
    expect(row.rows[0].void_entry_id).toBeNull();
  });

  it("cannot void a draft or an already-void invoice", async () => {
    const cust = await createCustomer(c, db.companyA, "Cant Void Draft Co");
    const draft = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await expect(
      c.query("SELECT void_invoice($1, $2, '2024-05-20', $3)", [db.companyA, draft, db.userA]),
    ).rejects.toThrow(/only open invoices/);
  });
});

describe("receive_payment and payment_applications", () => {
  it("full single-invoice payment closes the invoice's open balance to zero", async () => {
    const cust = await createCustomer(c, db.companyA, "Full Pay Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "200.0000", amount: "200.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "200.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "200.0000" }],
    });
    expect(await openBalance(c, db.companyA, inv)).toBe("0.0000");
  });

  it("a partial payment leaves the remainder open", async () => {
    const cust = await createCustomer(c, db.companyA, "Partial Pay Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "200.0000", amount: "200.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "75.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "75.0000" }],
    });
    expect(await openBalance(c, db.companyA, inv)).toBe("125.0000");
  });

  it("one payment can cover several invoices", async () => {
    const cust = await createCustomer(c, db.companyA, "Multi Invoice Co");
    const inv1 = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "60.0000", amount: "60.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const inv2 = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "40.0000", amount: "40.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv1, db.userA);
    await issueInvoice(c, db.companyA, inv2, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "100.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv1, amount: "60.0000" }, { invoiceId: inv2, amount: "40.0000" }],
    });
    expect(await openBalance(c, db.companyA, inv1)).toBe("0.0000");
    expect(await openBalance(c, db.companyA, inv2)).toBe("0.0000");
  });

  it("posts debit deposit account / credit AR for the payment", async () => {
    const cust = await createCustomer(c, db.companyA, "Posting Check Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "90.0000", amount: "90.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "90.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "90.0000" }],
    });
    const row = await c.query("SELECT posted_entry_id FROM 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["1000"].A), debit: "90.0000", credit: "0.0000" },
      { account_id: String(db.accounts["1100"].A), debit: "0.0000", credit: "90.0000" },
    ]);
  });

  it("an overpayment with no application at all is a pure customer credit", async () => {
    const cust = await createCustomer(c, db.companyA, "Pure Credit Co");
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "500.0000", depositAccountId: db.accounts["1000"].A,
    });
    const r = await c.query(
      "SELECT payment_unapplied_balance($1, $2) AS bal", [db.companyA, payId]);
    expect(r.rows[0].bal).toBe("500.0000");
  });

  it("rejects a deposit account that is not an asset", async () => {
    const cust = await createCustomer(c, db.companyA, "Bad Deposit Co");
    await expect(
      receivePayment(c, db.companyA, db.userA, {
        customerId: cust, amount: "10.0000", depositAccountId: db.accounts["4000"].A,
      }),
    ).rejects.toThrow(/asset account/);
  });

  it("rejects an application to an invoice belonging to a different customer", async () => {
    const custA = await createCustomer(c, db.companyA, "Customer Alpha");
    const custB = await createCustomer(c, db.companyA, "Customer Beta");
    const inv = await draftInvoice(c, db.companyA, db.userA, custA, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      receivePayment(c, db.companyA, db.userA, {
        customerId: custB, amount: "50.0000", depositAccountId: db.accounts["1000"].A,
        applications: [{ invoiceId: inv, amount: "50.0000" }],
      }),
    ).rejects.toThrow(/different customer|does not belong/);
  });
});

describe("over-application is rejected at commit (deferred constraint triggers)", () => {
  it("rejects applying more than the payment's own amount", async () => {
    const cust = await createCustomer(c, db.companyA, "Payment Overapply Co");
    const inv1 = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "1000.0000", amount: "1000.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv1, db.userA);
    // receive_payment validates amount<=... no: build directly via two applications summing over amount
    await expect(
      receivePayment(c, db.companyA, db.userA, {
        customerId: cust, amount: "50.0000", depositAccountId: db.accounts["1000"].A,
        applications: [{ invoiceId: inv1, amount: "999.0000" }],
      }),
    ).rejects.toThrow(/over-applied/);
  });

  it("rejects applying more than an invoice's own total across two payments", async () => {
    const cust = await createCustomer(c, db.companyA, "Invoice Overapply Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "60.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "60.0000" }],
    });
    await expect(
      receivePayment(c, db.companyA, db.userA, {
        customerId: cust, amount: "60.0000", depositAccountId: db.accounts["1000"].A,
        applications: [{ invoiceId: inv, amount: "60.0000" }], // 60 + 60 > 100
      }),
    ).rejects.toThrow(/over-applied/);
  });
});

describe("payment_applications are immutable", () => {
  it("no UPDATE or DELETE, not even by the superuser", async () => {
    const cust = await createCustomer(c, db.companyA, "Immutable App Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "40.0000", amount: "40.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "40.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "40.0000" }],
    });
    await expect(
      c.query("UPDATE payment_applications SET amount = '1.0000' WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      c.query("DELETE FROM payment_applications WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
    await expect(
      db.admin.query("UPDATE payment_applications SET amount = '1.0000' WHERE invoice_id = $1", [inv]),
    ).rejects.toThrow(/immutable/);
  });
});

describe("void_payment", () => {
  it("reverses the entry and its applications no longer count toward invoice open balance", async () => {
    const cust = await createCustomer(c, db.companyA, "Void Payment Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "150.0000", amount: "150.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "150.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "150.0000" }],
    });
    expect(await openBalance(c, db.companyA, inv)).toBe("0.0000");

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

    const row = await c.query("SELECT status, void_entry_id FROM payments WHERE id = $1", [payId]);
    expect(row.rows[0].status).toBe("void");
    expect(Number(row.rows[0].void_entry_id)).toBeGreaterThan(0);
    // invoice is open again for the full amount — the void payment's applications don't count
    expect(await openBalance(c, db.companyA, inv)).toBe("150.0000");
  });

  it("cannot void an already-void payment", async () => {
    const cust = await createCustomer(c, db.companyA, "Double Void Payment Co");
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "20.0000", depositAccountId: db.accounts["1000"].A,
    });
    await c.query("SELECT void_payment($1, $2, '2024-05-25', $3)", [db.companyA, payId, db.userA]);
    await expect(
      c.query("SELECT void_payment($1, $2, '2024-05-26', $3)", [db.companyA, payId, db.userA]),
    ).rejects.toThrow(/already void/);
  });
});

describe("apply_credit", () => {
  it("applies an existing payment's unapplied remainder to another invoice", async () => {
    const cust = await createCustomer(c, db.companyA, "Apply Credit Co");
    const inv1 = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "50.0000", amount: "50.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const inv2 = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "30.0000", amount: "30.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv1, db.userA);
    await issueInvoice(c, db.companyA, inv2, db.userA);
    // overpay invoice 1: pay 80 but only apply 50, leaving 30 unapplied
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "80.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv1, amount: "50.0000" }],
    });
    expect(await openBalance(c, db.companyA, inv2)).toBe("30.0000");

    await c.query(
      "SELECT apply_credit($1, $2, $3::jsonb, $4)",
      [db.companyA, payId, JSON.stringify([{ invoice_id: inv2, amount: "30.0000" }]), db.userA]);

    expect(await openBalance(c, db.companyA, inv2)).toBe("0.0000");
    const bal = await c.query("SELECT payment_unapplied_balance($1, $2) AS bal", [db.companyA, payId]);
    expect(bal.rows[0].bal).toBe("0.0000");
  });
});

describe("cross-company impossibility (CLAUDE.md §4)", () => {
  it("a payment_application cannot reference another company's invoice (composite FK)", async () => {
    const custA = await createCustomer(c, db.companyA, "FK Test Customer A");
    const invA = await draftInvoice(c, db.companyA, db.userA, custA, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, invA, db.userA);
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: custA, amount: "10.0000", depositAccountId: db.accounts["1000"].A,
    });
    // direct app-level insert is blocked entirely (function-only table), so this exercises
    // the grant hardening as well as (if it were bypassed) the composite FK
    await expect(
      c.query(
        `INSERT INTO payment_applications (company_id, payment_id, invoice_id, amount)
         VALUES ($1, $2, 999999, '5.0000')`,
        [db.companyA, payId]),
    ).rejects.toThrow(/permission denied/);
  });

  it("an invoice cannot reference another company's customer (composite FK)", async () => {
    const custB = await createCustomer(await db.appClient(db.companyB), db.companyB, "Customer Only In B");
    await expect(
      draftInvoice(c, db.companyA, db.userA, custB, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
      }),
    ).rejects.toThrow(/foreign key|violates/);
  });

  it("an invoice line cannot reference another company's income account (composite FK)", async () => {
    const cust = await createCustomer(c, db.companyA, "FK Line Test Co");
    await expect(
      draftInvoice(c, db.companyA, db.userA, cust, {
        lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].B }],
      }),
    ).rejects.toThrow(/not found|foreign key|violates/);
  });
});

describe("RLS isolation for AR tables (CLAUDE.md §3.5, §4)", () => {
  it("with no app.company_id set, every AR table returns zero rows", async () => {
    await createCustomer(c, db.companyA, "Isolation Fixture Co");
    const anon = await db.appClient(null);
    for (const table of ["customers", "invoices", "invoice_lines", "payments", "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 customers or invoices", async () => {
    const cust = await createCustomer(c, db.companyA, "A-Only Customer");
    await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const cB = await db.appClient(db.companyB);
    const customers = await cB.query("SELECT * FROM customers WHERE name = 'A-Only Customer'");
    expect(customers.rows.length).toBe(0);
  });

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

describe("ar_aging", () => {
  it("buckets invoices correctly and includes document_only invoices and unapplied credit", async () => {
    const cust = await createCustomer(c, db.companyA, "Aging Customer");
    const asOf = "2024-08-01";

    // current: due in the future
    const invCurrent = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-07-25", dueDate: "2024-08-15",
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // 1-30 past due: due 10 days ago
    const inv1to30 = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-07-01", dueDate: "2024-07-22",
      lines: [{ unitPrice: "200.0000", amount: "200.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // 31-60 past due: due 45 days ago
    const inv31to60 = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-06-01", dueDate: "2024-06-17",
      lines: [{ unitPrice: "300.0000", amount: "300.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // 61-90 past due: due 75 days ago
    const inv61to90 = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-05-01", dueDate: "2024-05-18",
      lines: [{ unitPrice: "400.0000", amount: "400.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // 90+ past due: due 200 days ago
    const inv90plus = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-01-01", dueDate: "2024-01-14",
      lines: [{ unitPrice: "500.0000", amount: "500.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // document_only, past due (47 days) — should still appear, bucket 31-60
    const invDocOnly = await draftInvoice(c, db.companyA, db.userA, cust, {
      documentOnly: true, invoiceDate: "2024-05-16", dueDate: "2024-06-15",
      lines: [{ unitPrice: "2789.80", amount: "2789.80", incomeAccountId: db.accounts["4000"].A }],
    });
    // voided invoice — should NOT appear
    const invVoided = await draftInvoice(c, db.companyA, db.userA, cust, {
      dueDate: "2024-07-01",
      lines: [{ unitPrice: "999.0000", amount: "999.0000", incomeAccountId: db.accounts["4000"].A }],
    });

    for (const inv of [invCurrent, inv1to30, inv31to60, inv61to90, inv90plus, invDocOnly, invVoided]) {
      await issueInvoice(c, db.companyA, inv, db.userA);
    }
    await c.query("SELECT void_invoice($1, $2, $3, $4)", [db.companyA, invVoided, asOf, db.userA]);

    // an unapplied credit for this customer
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "77.0000", depositAccountId: db.accounts["1000"].A,
    });

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

    expect(byInvoice.get(invCurrent)?.bucket).toBe("current");
    expect(byInvoice.get(inv1to30)?.bucket).toBe("1-30");
    expect(byInvoice.get(inv31to60)?.bucket).toBe("31-60");
    expect(byInvoice.get(inv61to90)?.bucket).toBe("61-90");
    expect(byInvoice.get(inv90plus)?.bucket).toBe("90+");
    expect(byInvoice.get(invDocOnly)?.bucket).toBe("31-60");
    expect(byInvoice.get(invDocOnly)?.document_only).toBe(true);
    expect(byInvoice.get(invDocOnly)?.open_amount).toBe("2789.8000");
    expect(byInvoice.has(invVoided)).toBe(false);

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

describe("derived open balances cross-checked in SQL", () => {
  it("invoice_open_balance matches a from-scratch SQL computation", async () => {
    const cust = await createCustomer(c, db.companyA, "Cross-Check Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [
        { unitPrice: "70.0000", amount: "70.0000", incomeAccountId: db.accounts["4000"].A },
        { unitPrice: "30.0000", amount: "30.0000", incomeAccountId: db.accounts["4000"].A },
      ],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "40.0000", depositAccountId: db.accounts["1000"].A,
      applications: [{ invoiceId: inv, amount: "40.0000" }],
    });

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

// ---- review-fix regression tests (db/migrations/0006_ar_fixes.sql) -------

describe("FIX 1: concurrent applications cannot over-apply an invoice", () => {
  it("two concurrent apply_credit calls applying 60+60 to a 100 invoice: exactly one succeeds", async () => {
    const cust = await createCustomer(c, db.companyA, "Race Customer");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    // two separate payments, each with 60 of unapplied credit for the same customer
    const payA = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "60.0000", depositAccountId: db.accounts["1000"].A,
    });
    const payB = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "60.0000", depositAccountId: db.accounts["1000"].A,
    });

    const a = await db.appClient(db.companyA);
    const b = await db.appClient(db.companyA);
    await a.query("BEGIN");
    await applyCredit(a, db.companyA, payA, [{ invoiceId: inv, amount: "60.0000" }], db.userA);
    // b races in concurrently against the same invoice, from a different payment;
    // it must block on the invoice lock, not race past the deferred check
    const pending = inFlight(
      applyCredit(b, db.companyA, payB, [{ invoiceId: inv, amount: "60.0000" }], db.userA));
    await new Promise((r) => setTimeout(r, 300));
    // Blocked on the invoice's row lock — the premise of the whole test. If B
    // had already finished, "exactly one succeeds" would be timing, not the
    // deferred check being unable to be raced.
    pending.stillBlocked("B's apply_credit");
    await a.query("COMMIT");
    await expect(pending.settled()).rejects.toThrow(/over-applied/);

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

  it("the same race also cannot slip past receive_payment applying to an existing invoice", async () => {
    const cust = await createCustomer(c, db.companyA, "Race Customer 2");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);

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

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

describe("FIX 2: ensure_ar_account requires exactly one active match", () => {
  it("raises a loud English error when no 'Accounts Receivable' account exists", async () => {
    const scratch = await createScratchCompany("No AR Account Co", []);
    const cScratch = await db.appClient(scratch.companyId);
    const cust = await createCustomer(cScratch, scratch.companyId, "Some Customer");
    const inv = await draftInvoice(cScratch, scratch.companyId, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: scratch.revenueAccountId }],
    });
    await expect(issueInvoice(cScratch, scratch.companyId, inv, db.userA)).rejects.toThrow(
      /No account named 'Accounts Receivable' exists — create one or set it in Settings/);
  });

  it("raises a loud English error when more than one active 'Accounts Receivable' account exists", async () => {
    const scratch = await createScratchCompany("Ambiguous AR Co", [
      { name: "Accounts Receivable" }, { name: "Accounts Receivable" },
    ]);
    const cScratch = await db.appClient(scratch.companyId);
    const cust = await createCustomer(cScratch, scratch.companyId, "Some Customer");
    const inv = await draftInvoice(cScratch, scratch.companyId, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: scratch.revenueAccountId }],
    });
    await expect(issueInvoice(cScratch, scratch.companyId, inv, db.userA)).rejects.toThrow(
      /More than one active account is named 'Accounts Receivable' — set the one to use in Settings/);
  });

  it("ignores an inactive 'Accounts Receivable' account and uses the one active match", async () => {
    const scratch = await createScratchCompany("Inactive AR Co", [
      { name: "Accounts Receivable", active: false }, { name: "Accounts Receivable", active: true },
    ]);
    const cScratch = await db.appClient(scratch.companyId);
    const cust = await createCustomer(cScratch, scratch.companyId, "Some Customer");
    const inv = await draftInvoice(cScratch, scratch.companyId, db.userA, cust, {
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: scratch.revenueAccountId }],
    });
    await expect(issueInvoice(cScratch, scratch.companyId, inv, db.userA)).resolves.toBeGreaterThan(0);
  });
});

describe("FIX 3: 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 cust = await createCustomer(c, db.companyA, "Payment Grant Co");
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "25.0000", depositAccountId: db.accounts["1000"].A,
    });
    await expect(
      c.query("UPDATE payments SET memo = 'edited' WHERE id = $1", [payId]),
    ).rejects.toThrow(/immutable|voided through/);
    await expect(
      c.query("DELETE FROM payments WHERE id = $1", [payId]),
    ).rejects.toThrow(/immutable|voided through/);
  });
});

describe("FIX 4: 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 cust = await createCustomer(c, db.companyA, "Voided Credit Co");
    const payId = await receivePayment(c, db.companyA, db.userA, {
      customerId: cust, amount: "35.0000", depositAccountId: db.accounts["1000"].A,
    });
    expect(await unappliedBalance(c, db.companyA, payId)).toBe("35.0000");

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

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

// ---- AR milestone closeout (db/migrations/0008_ar_closeout.sql) ----------
//
// DECISION 1: locks freeze document_only invoices too — before 0008,
// document_only's issue/void branches never called post_entry()/
// reverse_entry() at all, so neither function's period-lock check (nor
// reverse_entry_internal's "can't precede the entry it reverses" ordering
// check) ever ran for them. All four tests below share ONE period lock
// (locked_through '2024-06-30') inserted once at the top, since period_locks
// is append-only and the effective lock is always the latest row (0002) —
// every date chosen below is picked so each test exercises exactly the one
// guard it's named for, regardless of what ran before it in this describe
// block.

describe("DECISION 1 (0008): period locks cover document_only invoices too", () => {
  it("refuses to issue a document_only invoice dated inside a locked period, with the same English-mapped text post_entry uses", async () => {
    const cust = await createCustomer(c, db.companyA, "Locked Doc Issue Co");
    await c.query(
      `INSERT INTO period_locks (company_id, locked_through, created_by)
       VALUES ($1, '2024-06-30', $2)`, [db.companyA, db.userA]);
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      documentOnly: true, invoiceDate: "2024-06-15", dueDate: "2024-07-15",
      lines: [{ unitPrice: "100.0000", amount: "100.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    // exact same RAISE text as post_entry() (0002) — the identical
    // errors.ts mapping fires for both document_only and normal invoices.
    await expect(issueInvoice(c, db.companyA, inv, db.userA))
      .rejects.toThrow(/cannot post into locked period \(locked through/);
  });

  it("refuses to void a document_only invoice with a void date inside the locked period", async () => {
    const cust = await createCustomer(c, db.companyA, "Locked Doc Void Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      // dated AFTER the 2024-06-30 lock, so issuing succeeds — this test is
      // specifically about the VOID date, not the invoice date.
      documentOnly: true, invoiceDate: "2024-07-05", dueDate: "2024-08-04",
      lines: [{ unitPrice: "200.0000", amount: "200.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      c.query("SELECT void_invoice($1, $2, '2024-06-20', $3)", [db.companyA, inv, db.userA]),
    ).rejects.toThrow(/cannot post into locked period \(locked through/);
  });

  it("refuses to void a document_only invoice with a void date before its own invoice date", async () => {
    const cust = await createCustomer(c, db.companyA, "Doc Void Before Issue Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      documentOnly: true, invoiceDate: "2024-07-10", dueDate: "2024-08-09",
      lines: [{ unitPrice: "75.0000", amount: "75.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    // 2024-07-05 is after the 2024-06-30 lock (so that guard doesn't fire)
    // but before this invoice's own 2024-07-10 invoice_date — isolates the
    // ordering guard from the lock guard.
    await expect(
      c.query("SELECT void_invoice($1, $2, '2024-07-05', $3)", [db.companyA, inv, db.userA]),
    ).rejects.toThrow(/reversal date cannot precede the entry it reverses/);
  });

  it("normal (non-document_only) invoices are unaffected: post_entry/reverse_entry still own these checks, unchanged", async () => {
    const cust = await createCustomer(c, db.companyA, "Normal Unaffected Co");

    // Still refused when dated inside the lock — post_entry()'s own
    // existing check, completely untouched by 0008.
    const lockedDraft = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-06-15", dueDate: "2024-07-15",
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await expect(issueInvoice(c, db.companyA, lockedDraft, db.userA))
      .rejects.toThrow(/cannot post into locked period/);

    // Issues and voids normally once dated after the lock.
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-07-20", dueDate: "2024-08-19",
      lines: [{ unitPrice: "400.0000", amount: "400.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    await issueInvoice(c, db.companyA, inv, db.userA);
    await c.query("SELECT void_invoice($1, $2, '2024-08-01', $3)", [db.companyA, inv, db.userA]);
    const row = await c.query("SELECT status, void_entry_id FROM invoices WHERE id = $1", [inv]);
    expect(row.rows[0].status).toBe("void");
    expect(Number(row.rows[0].void_entry_id)).toBeGreaterThan(0);
  });
});

// DECISION 2: settable next invoice number — set_next_invoice_no() is a
// fast-forward-only counter override (never a rewind past a number already
// issued).
describe("DECISION 2 (0008): set_next_invoice_no", () => {
  it("refuses a value that does not exceed the highest invoice_no already issued", async () => {
    const cust = await createCustomer(c, db.companyA, "Set Counter Too Low Co");
    // dated after the 2024-06-30 lock inserted in the DECISION 1 describe
    // block above (period_locks is append-only and shared for the rest of
    // this file's company A connection).
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-08-01", dueDate: "2024-08-31",
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const no = await issueInvoice(c, db.companyA, inv, db.userA);
    await expect(
      c.query("SELECT set_next_invoice_no($1, $2, $3)", [db.companyA, no, db.userA]),
    ).rejects.toThrow(/next invoice number must be greater than the highest used/);
    await expect(
      c.query("SELECT set_next_invoice_no($1, $2, $3)", [db.companyA, no - 1, db.userA]),
    ).rejects.toThrow(/next invoice number must be greater than the highest used/);
  });

  it("fast-forwards the counter, and the next issued invoice picks up the new number", async () => {
    const cust = await createCustomer(c, db.companyA, "Set Counter OK Co");
    await c.query("SELECT set_next_invoice_no($1, 80, $2)", [db.companyA, db.userA]);
    const counterRow = await c.query(
      "SELECT next_invoice_no FROM invoice_counters WHERE company_id = $1", [db.companyA]);
    expect(Number(counterRow.rows[0].next_invoice_no)).toBe(80);

    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-08-05", dueDate: "2024-09-04",
      lines: [{ unitPrice: "20.0000", amount: "20.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const no = await issueInvoice(c, db.companyA, inv, db.userA);
    expect(no).toBe(80);
  });

  it("company B's counter is unaffected by company A's set_next_invoice_no call", async () => {
    const cB = await db.appClient(db.companyB);
    const custB = await createCustomer(cB, db.companyB, "Counter Isolation Co B");
    const invB = await draftInvoice(cB, db.companyB, db.userB, custB, {
      lines: [{ unitPrice: "5.0000", amount: "5.0000", incomeAccountId: db.accounts["4000"].B }],
    });
    const noB = await issueInvoice(cB, db.companyB, invB, db.userB);
    expect(noB).toBeLessThan(80); // company A was fast-forwarded to 80+, company B was not
  });

  // db/migrations/0010_counter_lock_ordering.sql — TOCTOU fix (owner review,
  // shared with AP's set_next_check_no): set_next_invoice_no() used to read
  // max(invoice_no) BEFORE locking invoice_counters, so a concurrent
  // issue_invoice() (which locks that row FIRST, mirrored here) could commit
  // a brand-new invoice number in the gap, leaving the setter validating
  // against a now-stale max. Reproduced with real overlapping transactions,
  // like the FIX 1 race test above: A holds issue_invoice() open
  // (uncommitted) while B calls set_next_invoice_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 (which is what the old ordering would have done,
  // corrupting the counter to a value that would collide with the number A
  // just issued).
  it("set_next_invoice_no serializes against a concurrent issue_invoice — no stale fast-forward", async () => {
    const cust = await createCustomer(c, db.companyA, "Counter Race Co");
    const inv = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-09-01", dueDate: "2024-09-30",
      lines: [{ unitPrice: "10.0000", amount: "10.0000", incomeAccountId: db.accounts["4000"].A }],
    });

    const a = await db.appClient(db.companyA); // issues the invoice, held open
    const b = await db.appClient(db.companyA); // races set_next_invoice_no

    await a.query("BEGIN");
    const issueRes = await a.query(
      "SELECT issue_invoice($1, $2, $3) AS no", [db.companyA, inv, db.userA]);
    const issuedNo = Number(issueRes.rows[0].no);

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

    // The counter is left exactly where issue_invoice() put it — B's failed
    // attempt changed nothing.
    const counterRow = await c.query(
      "SELECT next_invoice_no FROM invoice_counters WHERE company_id = $1", [db.companyA]);
    expect(Number(counterRow.rows[0].next_invoice_no)).toBe(issuedNo + 1);

    // A subsequent issue 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 inv2 = await draftInvoice(c, db.companyA, db.userA, cust, {
      invoiceDate: "2024-09-02", dueDate: "2024-10-01",
      lines: [{ unitPrice: "5.0000", amount: "5.0000", incomeAccountId: db.accounts["4000"].A }],
    });
    const no2 = await issueInvoice(c, db.companyA, inv2, db.userA);
    expect(no2).toBe(issuedNo + 1);
  });
});
