// Inventory & manufacturing invariants — db/migrations/0018_inventory.sql
// (Task 1 of docs/superpowers/plans/2026-08-03-inventory.md; spec:
// docs/superpowers/specs/2026-08-03-inventory-design.md).
// Mirrors tests/invariants/ar.test.ts / salestax.test.ts's per-run database +
// app-role RLS connection patterns exactly.
//
// THE HEADLINE INVARIANT of this milestone is check_inventory_ties(): the
// summed value of inventory_movements must equal the ledger balance of every
// account that is some item's inventory account, at every date, for every
// company. Tasks 2 and 3 build the document flows (bills, invoices, builds,
// adjustments) that pair a movement with its journal entry; at THIS layer the
// pairing is done by hand in moveAndPost() below, in the same order and the
// same transaction those functions will use — open a draft entry, record the
// movement (which is what COMPUTES the cost the entry needs), write the lines
// from the movement's own value, post. The tie is then asserted after every
// single operation.
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
let cB: Client;  // app client scoped to companyB

// fixtures living in the shared companyA / companyB pair (used by the RLS,
// immutability and armor blocks). Everything that asserts absolute money
// figures uses its own scratch company instead — see newBooks().
let invA = 0, cogsA = 0, invB = 0, cogsB = 0;
let fixtureItemA = 0, fixtureItemB = 0, subItemB = 0;
let fixtureMovementA = 0;
let sysA: Client, sysB: Client; // company-scoped superuser connections

beforeAll(async () => {
  db = await createTestDb();
  c = await db.appClient(db.companyA);
  cB = await db.appClient(db.companyB);

  invA = await addAccount(c, db.companyA, "1300", "Inventory", "asset");
  cogsA = await addAccount(c, db.companyA, "5000", "Cost of Goods Sold", "expense");
  invB = await addAccount(cB, db.companyB, "1300", "Inventory", "asset");
  cogsB = await addAccount(cB, db.companyB, "5000", "Cost of Goods Sold", "expense");

  fixtureItemA = await createItem(c, db.companyA, {
    name: "Fixture A", inventoryAccountId: invA, cogsAccountId: cogsA,
  });
  fixtureItemB = await createItem(cB, db.companyB, {
    name: "Fixture B", inventoryAccountId: invB, cogsAccountId: cogsB,
  });
  subItemB = await createItem(cB, db.companyB, {
    name: "Sub B", inventoryAccountId: invB, cogsAccountId: cogsB,
  });

  // record_movement is internal, so the fixture movements go in over
  // company-scoped superuser connections (see newBooks' comment).
  const booksA: Books = {
    companyId: db.companyA, client: await db.sysClient(db.companyA),
    inventory: invA, cogs: cogsA,
    ap: db.accounts["2000"].A, income: db.accounts["4000"].A,
  };
  const booksB: Books = {
    companyId: db.companyB, client: await db.sysClient(db.companyB),
    inventory: invB, cogs: cogsB,
    ap: db.accounts["2000"].B, income: db.accounts["4000"].B,
  };
  sysA = booksA.client;
  sysB = booksB.client;
  fixtureMovementA = (await moveAndPost(booksA, {
    itemId: fixtureItemA, movedOn: "2024-01-02", qty: "10", unitCost: "1.000000",
    reason: "receipt", debit: invA, credit: db.accounts["2000"].A,
  })).movementId;
  await moveAndPost(booksB, {
    itemId: fixtureItemB, movedOn: "2024-01-02", qty: "10", unitCost: "1.000000",
    reason: "receipt", debit: invB, credit: db.accounts["2000"].B,
  });
}, 90_000);

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

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

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

interface ItemSpec {
  name: string;
  kind?: string;
  sku?: string | null;
  inventoryAccountId?: number | null;
  cogsAccountId?: number | null;
  incomeAccountId?: number | null;
  salesPrice?: string | null;
  purchaseCost?: string | null;
}

async function createItem(
  client: Client, companyId: string, spec: ItemSpec,
): Promise<number> {
  const r = await client.query(
    `INSERT INTO items (company_id, name, kind, sku, inventory_account_id,
                        cogs_account_id, income_account_id, sales_price, purchase_cost)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
    [companyId, spec.name, spec.kind ?? "inventory", spec.sku ?? null,
     spec.inventoryAccountId ?? null, spec.cogsAccountId ?? null,
     spec.incomeAccountId ?? null, spec.salesPrice ?? null, spec.purchaseCost ?? null]);
  return Number(r.rows[0].id);
}

/** A self-contained set of books: its own company, chart, and app connection. */
interface Books {
  companyId: string;
  client: Client;
  inventory: number;
  cogs: number;
  ap: number;
  income: number;
  adjustment?: number;
  cash?: number;
}

async function newBooks(name: string): Promise<Required<Books>> {
  const comp = await db.admin.query(
    `INSERT INTO companies (name) VALUES ($1) RETURNING id`, [`${name} ${Date.now()}`]);
  const companyId: string = comp.rows[0].id;
  const mk = async (code: string, nm: string, type: string): Promise<number> => {
    const r = await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type) VALUES ($1,$2,$3,$4) RETURNING id`,
      [companyId, code, nm, type]);
    return Number(r.rows[0].id);
  };
  const cash = await mk("1000", "Cash", "asset");
  const inventory = await mk("1300", "Inventory", "asset");
  const ap = await mk("2000", "Accounts Payable", "liability");
  const income = await mk("4000", "Sales Income", "income");
  const cogs = await mk("5000", "Cost of Goods Sold", "expense");
  const adjustment = await mk("6900", "Inventory Adjustments", "expense");
  // A company-scoped SUPERUSER connection. record_movement is internal
  // (EXECUTE revoked from app), so these books stand in for the SECURITY
  // DEFINER document functions Tasks 2 and 3 will provide — the only things
  // entitled to write one half of a movement/entry pair. App-role behaviour is
  // probed explicitly through appFor() below, wherever it is the point.
  const client = await db.sysClient(companyId);
  return { companyId, client, inventory, cogs, ap, income, adjustment, cash };
}

/** Lazily-created app-role connection for a scratch company (one per company). */
const appClients = new Map<string, Promise<Client>>();
function appFor(bk: Books): Promise<Client> {
  let p = appClients.get(bk.companyId);
  if (p === undefined) {
    p = db.appClient(bk.companyId);
    appClients.set(bk.companyId, p);
  }
  return p;
}

/**
 * Record a movement with no journal entry of its own, and (optionally) no
 * entry at all — the raw door, used to probe the structural guards.
 */
async function recordMovementRaw(
  bk: Books,
  spec: {
    itemId: number; movedOn: string; qty: string; unitCost?: string | null;
    reason: string; entryId?: number | null;
  },
  client?: Client,
): Promise<number> {
  const r = await (client ?? bk.client).query(
    `SELECT record_movement($1, $2::bigint, $3::date, $4::numeric, $5::numeric,
                            $6::text, NULL, NULL, $7::bigint) AS id`,
    [bk.companyId, spec.itemId, spec.movedOn, spec.qty, spec.unitCost ?? null,
     spec.reason, spec.entryId ?? null]);
  return Number(r.rows[0].id);
}

/**
 * builds.entry_id is NOT NULL — every build posts an entry. These fixture
 * builds carry no movements, so their entry is a plain balanced pair on
 * NON-inventory accounts: it gives the FK something real to point at without
 * touching any inventory account (which would move the tie).
 */
async function postedFixtureEntry(bk: Books, on = "2024-03-01"): Promise<number> {
  await bk.client.query("BEGIN");
  const er = await bk.client.query(
    `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
     VALUES ($1,$2,'build fixture',$3) RETURNING id`, [bk.companyId, on, db.userA]);
  const entryId = Number(er.rows[0].id);
  await bk.client.query(
    `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
     VALUES ($1,$2,$3,'1.0000',0), ($1,$2,$4,0,'1.0000')`,
    [bk.companyId, entryId, bk.cogs, bk.ap]);
  await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
  await bk.client.query("COMMIT");
  return entryId;
}

/** movement row as stored. */
async function movementRow(
  bk: Books, id: number,
): Promise<{ qty: string; unit_cost: string; value: string }> {
  return (await bk.client.query(
    "SELECT qty, unit_cost, value FROM inventory_movements WHERE company_id=$1 AND id=$2",
    [bk.companyId, id])).rows[0];
}

/** One shared scratch company for the PO tests that assert no money figures. */
let poBooks: Required<Books> | null = null;
async function sharedPoBooks(): Promise<Required<Books>> {
  if (poBooks === null) poBooks = await newBooks("PO Shared Co");
  return poBooks;
}

async function stockItem(bk: Books, name: string, sku?: string): Promise<number> {
  return createItem(bk.client, bk.companyId, {
    name, kind: "inventory", sku,
    inventoryAccountId: bk.inventory, cogsAccountId: bk.cogs, incomeAccountId: bk.income,
  });
}

interface Movement {
  movementId: number;
  entryId: number;
  qty: string;
  unitCost: string;
  value: string;
}

/**
 * Record a movement and the journal entry it rides on, atomically — the exact
 * order Tasks 2 and 3 will use inside issue_bill()/issue_invoice()/post_build():
 * draft entry -> record_movement (computes the cost) -> journal lines from the
 * movement's own value -> post_entry. Because the entry uses the movement's
 * ROUNDED value, the ledger and the movement agree by construction.
 */
async function moveAndPost(
  bk: Books,
  spec: {
    itemId: number; movedOn: string; qty: string; unitCost?: string | null;
    reason: string; debit: number; credit: number; memo?: string;
    sourceKind?: string; sourceId?: number;
  },
): Promise<Movement> {
  const client = bk.client;
  await client.query("BEGIN");
  try {
    const er = await client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,$2,$3,$4) RETURNING id`,
      [bk.companyId, spec.movedOn, spec.memo ?? spec.reason, db.userA]);
    const entryId = Number(er.rows[0].id);

    const mr = await client.query(
      `SELECT record_movement($1, $2::bigint, $3::date, $4::numeric, $5::numeric,
                              $6::text, $7::text, $8::bigint, $9::bigint) AS id`,
      [bk.companyId, spec.itemId, spec.movedOn, spec.qty, spec.unitCost ?? null,
       spec.reason, spec.sourceKind ?? null, spec.sourceId ?? null, entryId]);
    const movementId = Number(mr.rows[0].id);

    const m = await client.query(
      `SELECT qty, unit_cost, value FROM inventory_movements
        WHERE company_id = $1 AND id = $2`, [bk.companyId, movementId]);
    const value: string = m.rows[0].value;
    const amount = value.startsWith("-") ? value.slice(1) : value;

    await client.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,$4,0), ($1,$2,$5,0,$4)`,
      [bk.companyId, entryId, spec.debit, amount, spec.credit]);

    await client.query("SELECT post_entry($1, $2)", [bk.companyId, entryId]);
    await client.query("COMMIT");
    return {
      movementId, entryId,
      qty: m.rows[0].qty, unitCost: m.rows[0].unit_cost, value,
    };
  } catch (err) {
    await client.query("ROLLBACK").catch(() => {});
    throw err;
  }
}

async function qtyOnHand(bk: Books, itemId: number, asOf?: string): Promise<string> {
  const r = await bk.client.query(
    "SELECT item_qty_on_hand($1, $2, $3::date) AS q", [bk.companyId, itemId, asOf ?? null]);
  return r.rows[0].q;
}

async function avgCost(bk: Books, itemId: number, asOf?: string): Promise<string> {
  const r = await bk.client.query(
    "SELECT item_average_cost($1, $2, $3::date) AS a", [bk.companyId, itemId, asOf ?? null]);
  return r.rows[0].a;
}

interface Tie {
  account_id: string; code: string; name: string;
  ledger_value: string; movement_value: string; difference: string;
}

/** One row per inventory account. */
async function tieRows(bk: Books, asOf?: string): Promise<Tie[]> {
  const r = await bk.client.query(
    "SELECT * FROM check_inventory_ties($1, $2::date)", [bk.companyId, asOf ?? null]);
  return r.rows as Tie[];
}

/** The single inventory account's row (most books have exactly one). */
async function tie(bk: Books, asOf?: string): Promise<Tie> {
  const rows = await tieRows(bk, asOf);
  expect(rows.length).toBe(1);
  return rows[0]!;
}

/** Inventory's ledger_out_of_balance(): sums ABSOLUTE per-account differences. */
async function outOfTie(bk: Books, asOf?: string): Promise<string> {
  const r = await bk.client.query(
    "SELECT inventory_out_of_tie($1, $2::date) AS d", [bk.companyId, asOf ?? null]);
  return r.rows[0].d;
}

/** The assertion this whole milestone exists to make. */
async function expectTied(bk: Books, asOf?: string): Promise<Tie> {
  expect(await outOfTie(bk, asOf)).toBe("0.0000");
  return tie(bk, asOf);
}

async function accountBalance(bk: Books, accountId: number): Promise<string> {
  const r = await bk.client.query(
    `SELECT coalesce(sum(l.debit - l.credit), 0)::numeric(19,4) AS bal
       FROM journal_lines l
       JOIN journal_entries e ON e.company_id = l.company_id AND e.id = l.entry_id
      WHERE l.company_id = $1 AND l.account_id = $2 AND e.status = 'posted'`,
    [bk.companyId, accountId]);
  return r.rows[0].bal;
}

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

async function draftPo(
  bk: Books, vendorId: number,
  lines: Array<{ itemId?: number; description?: string; qty: string; unitCost: string }>,
): Promise<number> {
  const r = await bk.client.query(
    `INSERT INTO purchase_orders (company_id, vendor_id, order_date, created_by)
     VALUES ($1,$2,'2024-02-01',$3) RETURNING id`, [bk.companyId, vendorId, db.userA]);
  const poId = Number(r.rows[0].id);
  for (const l of lines) {
    await bk.client.query(
      `INSERT INTO purchase_order_lines
         (company_id, po_id, item_id, description, qty_ordered, unit_cost)
       VALUES ($1,$2,$3,$4,$5,$6)`,
      [bk.companyId, poId, l.itemId ?? null, l.description ?? null, l.qty, l.unitCost]);
  }
  return poId;
}

// ===========================================================================
// THE TIE — asserted after every operation type available at this layer
// ===========================================================================

describe("check_inventory_ties: inventory ties to the ledger, always", () => {
  it("ties after a receipt, an issue, and an adjustment — to the penny, at every date", async () => {
    const bk = await newBooks("Tie Co");
    const widget = await stockItem(bk, "Widget");

    // Nothing has happened yet: both derivations are zero and agree.
    let t = await expectTied(bk);
    expect(t.ledger_value).toBe("0.0000");
    expect(t.movement_value).toBe("0.0000");

    // (1) RECEIPT — 40 @ 5.25 = 210.0000. Dr Inventory / Cr A/P.
    const receipt = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "40", unitCost: "5.250000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(receipt.value).toBe("210.0000");
    t = await expectTied(bk);
    expect(t.ledger_value).toBe("210.0000");
    expect(t.movement_value).toBe("210.0000");

    // (2) ISSUE (a sale's cost side) — 15 out at the running average 5.25 =
    //     78.7500. Dr COGS / Cr Inventory.
    const issue = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-04-01", qty: "-15", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(issue.unitCost).toBe("5.250000");
    expect(issue.value).toBe("-78.7500");
    t = await expectTied(bk);
    expect(t.ledger_value).toBe("131.2500"); // 210.00 - 78.75
    expect(t.movement_value).toBe("131.2500");

    // (3) ADJUSTMENT down (shrinkage) — 5 out at 5.25 = 26.2500.
    //     Dr Inventory Adjustments / Cr Inventory.
    const shrink = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-05-01", qty: "-5", reason: "adjustment",
      debit: bk.adjustment!, credit: bk.inventory,
    });
    expect(shrink.value).toBe("-26.2500");
    t = await expectTied(bk);
    expect(t.ledger_value).toBe("105.0000"); // 131.25 - 26.25
    expect(t.movement_value).toBe("105.0000");

    // (4) ADJUSTMENT up (count correction found 2 more, valued at cost).
    //     Dr Inventory / Cr Inventory Adjustments.
    const found = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-06-01", qty: "2", unitCost: "5.250000",
      reason: "adjustment", debit: bk.inventory, credit: bk.adjustment!,
    });
    expect(found.value).toBe("10.5000");
    t = await expectTied(bk);
    expect(t.ledger_value).toBe("115.5000");
    expect(t.movement_value).toBe("115.5000");

    // (5) OPENING quantity — a second item, brought in against equity-like
    //     adjustment here. Ties just the same.
    const gizmo = await stockItem(bk, "Gizmo");
    const opening = await moveAndPost(bk, {
      itemId: gizmo, movedOn: "2024-06-15", qty: "3", unitCost: "100.000000",
      reason: "opening", debit: bk.inventory, credit: bk.adjustment!,
    });
    expect(opening.value).toBe("300.0000");
    t = await expectTied(bk);
    expect(t.movement_value).toBe("415.5000");

    // The tie holds at EVERY date, not just today — both sides are filtered
    // by their own date column and must agree at every point in between.
    for (const asOf of ["2024-02-28", "2024-03-01", "2024-03-15", "2024-04-01",
                        "2024-04-30", "2024-05-01", "2024-06-01", "2024-06-15",
                        "2030-01-01"]) {
      await expectTied(bk, asOf);
    }
    // Spot-check the point-in-time numbers themselves, so the loop above
    // cannot be passing vacuously on two zeros.
    expect((await tie(bk, "2024-02-28")).movement_value).toBe("0.0000");
    expect((await tie(bk, "2024-03-15")).movement_value).toBe("210.0000");
    expect((await tie(bk, "2024-04-30")).movement_value).toBe("131.2500");

    // and the ledger itself never went out of balance doing any of it
    const oob = await bk.client.query(
      "SELECT ledger_out_of_balance($1) AS d", [bk.companyId]);
    expect(oob.rows[0].d).toBe("0.0000");
  });

  it("a journal entry that hits the inventory account WITHOUT a movement breaks the tie loudly", async () => {
    // The tie is only worth having if it can fail AND be seen. As of
    // db/migrations/0022_inventory_ledger_gate.sql this exact mistake — a
    // manual entry straight into the Inventory account — can no longer be
    // MADE: check_entry_stock_backed() refuses it at COMMIT. Both halves are
    // asserted here, because the report is still the backstop for anything
    // that ever gets in another way (a restore, a future bug), and a reporting
    // function nobody proves can report is decoration.
    const bk = await newBooks("Broken Tie Co");
    const widget = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "10", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await expectTied(bk);

    const rogue = async (): Promise<void> => {
      await bk.client.query("BEGIN");
      const er = await bk.client.query(
        `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
         VALUES ($1,'2024-03-02','rogue inventory entry',$2) RETURNING id`,
        [bk.companyId, db.userA]);
      const entryId = Number(er.rows[0].id);
      await bk.client.query(
        `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
         VALUES ($1,$2,$3,'25.0000',0), ($1,$2,$4,0,'25.0000')`,
        [bk.companyId, entryId, bk.inventory, bk.ap]);
      await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
      await bk.client.query("COMMIT");
    };

    // (1) it is refused, in English, naming the account and both cures
    await expect(rogue()).rejects.toThrow(/it is an inventory account/);
    await bk.client.query("ROLLBACK").catch(() => { /* already rolled back */ });
    await expectTied(bk);

    // (2) and if it ever DID get in, the report says so out loud. The trigger
    // is disabled as the table owner for exactly one statement — the app role
    // cannot do this, which is the point.
    await db.admin.query("ALTER TABLE journal_entries DISABLE TRIGGER entry_stock_backed");
    try {
      await rogue();
    } finally {
      await db.admin.query("ALTER TABLE journal_entries ENABLE TRIGGER entry_stock_backed");
    }

    const t = await tie(bk);
    expect(t.ledger_value).toBe("35.0000");
    expect(t.movement_value).toBe("10.0000");
    expect(t.difference).toBe("25.0000");
  });
});

// ===========================================================================
// Average costing
// ===========================================================================

describe("average cost", () => {
  it("receipt, issue, receipt, issue — hand-computed averages at every step", async () => {
    // Hand-computed, stated in full:
    //   1. +100 @ 2.500000            value  250.0000 | qty 100 | value 250.0000
    //                                 average 250.0000/100          = 2.500000
    //   2.  -30 @ 2.500000            value  -75.0000 | qty  70 | value 175.0000
    //                                 average 175.0000/70           = 2.500000
    //   3.  +50 @ 3.200000            value  160.0000 | qty 120 | value 335.0000
    //                                 average 335.0000/120 = 2.79166666… -> 2.791667
    //   4.  -40 @ 2.791667  (40 x 2.791667 = 111.66668 -> HALF-UP 111.6667)
    //                                 value -111.6667 | qty  80 | value 223.3333
    //                                 average 223.3333/80  = 2.79166625  -> 2.791666
    // Note step 4's average drifts one millionth BELOW step 3's: the cost
    // actually applied was rounded to four decimals when it hit the books,
    // and the average honestly reflects what was applied, not an idealised
    // recomputation. That is the point of storing the value.
    const bk = await newBooks("Average Co");
    const widget = await stockItem(bk, "Widget");

    const m1 = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "100", unitCost: "2.500000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(m1.value).toBe("250.0000");
    expect(await qtyOnHand(bk, widget)).toBe("100.0000");
    expect(await avgCost(bk, widget)).toBe("2.500000");
    await expectTied(bk);

    const m2 = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-02", qty: "-30", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(m2.unitCost).toBe("2.500000");
    expect(m2.value).toBe("-75.0000");
    expect(await qtyOnHand(bk, widget)).toBe("70.0000");
    expect(await avgCost(bk, widget)).toBe("2.500000");
    await expectTied(bk);

    const m3 = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-03", qty: "50", unitCost: "3.200000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(m3.value).toBe("160.0000");
    expect(await qtyOnHand(bk, widget)).toBe("120.0000");
    expect(await avgCost(bk, widget)).toBe("2.791667");
    await expectTied(bk);

    const m4 = await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-04", qty: "-40", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(m4.unitCost).toBe("2.791667");
    expect(m4.value).toBe("-111.6667");
    expect(await qtyOnHand(bk, widget)).toBe("80.0000");
    expect(await avgCost(bk, widget)).toBe("2.791666");

    const t = await expectTied(bk);
    expect(t.ledger_value).toBe("223.3333"); // 250.0000 - 75.0000 + 160.0000 - 111.6667
    expect(t.movement_value).toBe("223.3333");
    expect(await accountBalance(bk, bk.cogs)).toBe("186.6667"); // 75.0000 + 111.6667

    // as-of costing reconstructs each step exactly
    expect(await avgCost(bk, widget, "2024-03-01")).toBe("2.500000");
    expect(await avgCost(bk, widget, "2024-03-03")).toBe("2.791667");
    expect(await qtyOnHand(bk, widget, "2024-03-02")).toBe("70.0000");
  });

  it("sub-cent unit costs survive intact (a washer costs $0.0042)", async () => {
    // Hand-computed:
    //   1. +10000 @ 0.004200  value  10000 x 0.0042 =  42.0000
    //   2.  -3333 @ 0.004200  value  -3333 x 0.0042 = -13.9986 (exact, no rounding)
    //      remaining qty 6667, value 28.0014, average 28.0014/6667 = 0.004200 exactly
    const bk = await newBooks("Washer Co");
    const washer = await stockItem(bk, "Washer");

    const m1 = await moveAndPost(bk, {
      itemId: washer, movedOn: "2024-03-01", qty: "10000", unitCost: "0.004200",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(m1.value).toBe("42.0000");
    expect(await avgCost(bk, washer)).toBe("0.004200");
    await expectTied(bk);

    const m2 = await moveAndPost(bk, {
      itemId: washer, movedOn: "2024-03-02", qty: "-3333", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(m2.unitCost).toBe("0.004200");
    expect(m2.value).toBe("-13.9986");
    expect(await qtyOnHand(bk, washer)).toBe("6667.0000");
    expect(await avgCost(bk, washer)).toBe("0.004200");

    const t = await expectTied(bk);
    expect(t.movement_value).toBe("28.0014");
  });

  it("value rounding is HALF-UP (half away from zero), in both directions", async () => {
    // These two cases are chosen because half-EVEN would give a different
    // answer, so they actually discriminate:
    //   +1 @ 0.000250 -> 0.00025 -> HALF-UP  0.0003  (half-even would be 0.0002)
    //   +4 @ 0.000250 -> 0.00100 -> exact    0.0010, average 0.000250
    //   -1 @ 0.000250 -> -0.00025 -> HALF-UP -0.0003 (half-even would be -0.0002)
    //      remaining qty 3, value 0.0007, average 0.0007/3 = 0.000233333… -> 0.000233
    const bk = await newBooks("Halfpenny Co");

    const up = await stockItem(bk, "Half Up");
    const mUp = await moveAndPost(bk, {
      itemId: up, movedOn: "2024-03-01", qty: "1", unitCost: "0.000250",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(mUp.value).toBe("0.0003");
    expect(await avgCost(bk, up)).toBe("0.000300");
    await expectTied(bk);

    const down = await stockItem(bk, "Half Down");
    const mIn = await moveAndPost(bk, {
      itemId: down, movedOn: "2024-03-01", qty: "4", unitCost: "0.000250",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(mIn.value).toBe("0.0010");
    expect(await avgCost(bk, down)).toBe("0.000250");
    await expectTied(bk);

    const mOut = await moveAndPost(bk, {
      itemId: down, movedOn: "2024-03-02", qty: "-1", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(mOut.unitCost).toBe("0.000250");
    expect(mOut.value).toBe("-0.0003");
    expect(await qtyOnHand(bk, down)).toBe("3.0000");
    expect(await avgCost(bk, down)).toBe("0.000233");

    const t = await expectTied(bk);
    expect(t.movement_value).toBe("0.0010"); // 0.0003 + 0.0010 - 0.0003
  });

  it("a quantity finer than four decimals is rounded ONCE, before it is costed", async () => {
    // qty 1.00005 @ 10000. Rounding only at the INSERT would test and cost
    // 1.00005 (value 10000.5000) but STORE 1.0001 — a row whose own
    // qty x unit_cost no longer equals its own value, and an average of
    // 9999.500050 for stock bought at 10000. Rounded once, up front:
    //   qty   round(1.00005, 4)          = 1.0001
    //   value round(1.0001 x 10000, 4)   = 10001.0000
    //   avg   10001.0000 / 1.0001        = 10000.000000
    const bk = await newBooks("Fine Quantity Co");
    const item = await stockItem(bk, "Bulk Powder");
    const m = await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "1.00005", unitCost: "10000.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(m.qty).toBe("1.0001");
    expect(m.unitCost).toBe("10000.000000");
    expect(m.value).toBe("10001.0000");
    expect(await qtyOnHand(bk, item)).toBe("1.0001");
    expect(await avgCost(bk, item)).toBe("10000.000000");
    const t = await expectTied(bk);
    expect(t.movement_value).toBe("10001.0000");
    // the stored row is internally consistent: qty x unit_cost IS value
    const consistent = await bk.client.query(
      `SELECT round(qty * unit_cost, 4) = value AS ok FROM inventory_movements
        WHERE company_id = $1 AND id = $2`, [bk.companyId, m.movementId]);
    expect(consistent.rows[0].ok).toBe(true);
  });

  it("emptying the shelf takes the whole remaining value — no stranded residue", async () => {
    // Hand-computed:
    //   +1     @ 1.000000  -> value  1.0000
    //   +29999 @ 0.000001  -> value  0.0300  (round(0.029999, 4))
    //   on hand 30000, value 1.0300, average round(1.03/30000, 6) = 0.000034
    //   -30000: round(30000 x 0.000034, 4) = 1.0200 — which would leave
    //           quantity 0 and value 0.0100 STRANDED in the Inventory account
    //           with nothing to attach it to, and no average that could ever
    //           carry it back out. So the last issue takes ALL 1.0300.
    const bk = await newBooks("Residue Co");
    const item = await stockItem(bk, "Dust");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    const m2 = await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "29999", unitCost: "0.000001",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(m2.value).toBe("0.0300");
    expect(await qtyOnHand(bk, item)).toBe("30000.0000");
    expect(await avgCost(bk, item)).toBe("0.000034");

    const out = await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-03", qty: "-30000", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(out.value).toBe("-1.0300");   // NOT -1.0200
    expect(await qtyOnHand(bk, item)).toBe("0.0000");
    expect(await avgCost(bk, item)).toBe("0.000000");

    const t = await expectTied(bk);
    expect(t.ledger_value).toBe("0.0000");
    expect(t.movement_value).toBe("0.0000");
    expect(await accountBalance(bk, bk.inventory)).toBe("0.0000");
    expect(await accountBalance(bk, bk.cogs)).toBe("1.0300");
  });

  it("average cost is 0 when nothing is on hand, and stock can go back to zero exactly", async () => {
    const bk = await newBooks("Zero Co");
    const item = await stockItem(bk, "Ephemeral");
    expect(await avgCost(bk, item)).toBe("0.000000");
    expect(await qtyOnHand(bk, item)).toBe("0.0000");

    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "5", unitCost: "3.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "-5", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(await qtyOnHand(bk, item)).toBe("0.0000");
    expect(await avgCost(bk, item)).toBe("0.000000");
    const t = await expectTied(bk);
    expect(t.movement_value).toBe("0.0000");
  });

  it("inventory_valuation agrees with item_qty_on_hand / item_average_cost and sums to the tie", async () => {
    const bk = await newBooks("Valuation Co");
    const a = await stockItem(bk, "Alpha Part", "ALPHA-1");
    const b = await stockItem(bk, "Beta Part");
    await moveAndPost(bk, {
      itemId: a, movedOn: "2024-03-01", qty: "12", unitCost: "1.333333",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await moveAndPost(bk, {
      itemId: b, movedOn: "2024-03-01", qty: "7", unitCost: "9.990000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    const rows = (await bk.client.query(
      "SELECT * FROM inventory_valuation($1, NULL)", [bk.companyId])).rows;
    expect(rows.map((r: { name: string }) => r.name)).toEqual(["Alpha Part", "Beta Part"]);
    expect(rows[0].sku).toBe("ALPHA-1");
    for (const row of rows) {
      const id = Number(row.item_id);
      expect(row.qty_on_hand).toBe(await qtyOnHand(bk, id));
      expect(row.average_cost).toBe(await avgCost(bk, id));
    }
    // Summed in SQL, in NUMERIC — never through a JS float (CLAUDE.md §2).
    const total: string = (await bk.client.query(
      "SELECT coalesce(sum(value), 0)::numeric(19,4) AS total FROM inventory_valuation($1, NULL)",
      [bk.companyId])).rows[0].total;
    const t = await expectTied(bk);
    expect(total).toBe(t.movement_value);
  });
});

// ===========================================================================
// Negative stock
// ===========================================================================

describe("negative stock is refused", () => {
  it("refuses an issue larger than what is on hand, in plain English", async () => {
    const bk = await newBooks("Oversell Co");
    const widget = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "12", unitCost: "4.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-02", qty: "-20", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    })).rejects.toThrow(
      /You only have 12 of Widget on hand — this would use 20\./);

    // nothing partial was left behind, and the tie is untouched
    expect(await qtyOnHand(bk, widget)).toBe("12.0000");
    const t = await expectTied(bk);
    expect(t.movement_value).toBe("48.0000");
  });

  it("refuses an adjustment or a build draw that would go negative, and allows going to exactly zero", async () => {
    const bk = await newBooks("Exact Zero Co");
    const part = await stockItem(bk, "Bracket");
    await moveAndPost(bk, {
      itemId: part, movedOn: "2024-03-01", qty: "8", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(moveAndPost(bk, {
      itemId: part, movedOn: "2024-03-02", qty: "-8.0001", reason: "adjustment",
      debit: bk.adjustment!, credit: bk.inventory,
    })).rejects.toThrow(/You only have 8 of Bracket on hand — this would use 8\.0001\./);

    await expect(moveAndPost(bk, {
      itemId: part, movedOn: "2024-03-02", qty: "-9", reason: "build_out",
      debit: bk.cogs, credit: bk.inventory,
    })).rejects.toThrow(/You only have 8 of Bracket on hand — this would use 9\./);

    // exactly zero is fine
    await moveAndPost(bk, {
      itemId: part, movedOn: "2024-03-02", qty: "-8", reason: "build_out",
      debit: bk.cogs, credit: bk.inventory,
    });
    expect(await qtyOnHand(bk, part)).toBe("0.0000");
    await expectTied(bk);
  });

  it("refuses to issue anything at all from an item that has never been received", async () => {
    const bk = await newBooks("Empty Shelf Co");
    const part = await stockItem(bk, "Never Stocked");
    await expect(moveAndPost(bk, {
      itemId: part, movedOn: "2024-03-01", qty: "-1", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    })).rejects.toThrow(/You only have 0 of Never Stocked on hand — this would use 1\./);
  });
});

// ===========================================================================
// record_movement is the only door, and it guards it
// ===========================================================================

describe("record_movement", () => {
  it("refuses a movement for an item that is not stocked", async () => {
    const bk = await newBooks("Service Co");
    const svc = await createItem(bk.client, bk.companyId, {
      name: "Consulting", kind: "service", incomeAccountId: bk.income,
    });
    const nonInv = await createItem(bk.client, bk.companyId, {
      name: "Drop Shipped Part", kind: "non_inventory",
      cogsAccountId: bk.cogs, incomeAccountId: bk.income,
    });
    await expect(moveAndPost(bk, {
      itemId: svc, movedOn: "2024-03-01", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/'Consulting' is not a stocked item, so it has no quantity on hand/);
    await expect(moveAndPost(bk, {
      itemId: nonInv, movedOn: "2024-03-01", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/'Drop Shipped Part' is not a stocked item/);
  });

  it("refuses a zero movement, an uncosted receipt, and a caller-costed issue", async () => {
    const bk = await newBooks("Guards Co");
    const item = await stockItem(bk, "Guarded Part");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "0", unitCost: "1.000000",
      reason: "adjustment", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/a stock movement has to change the quantity by something/);

    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "5", reason: "receipt",
      debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/receiving 'Guarded Part' needs a unit cost/);

    // The running average IS the cost of stock going out — a caller may not
    // supply a second opinion.
    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "-1", unitCost: "99.000000",
      reason: "sale", debit: bk.cogs, credit: bk.inventory,
    })).rejects.toThrow(
      /the cost of stock going out is set by the running average, not by the caller/);

    await expect(moveAndPost(bk, {
      itemId: 999999, movedOn: "2024-03-02", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/item 999999 not found/);

    await expectTied(bk);
  });

  it("refuses to run against another company's session", async () => {
    // sysB is scoped to companyB; even with EXECUTE (it is a superuser
    // connection), the session-company guard refuses companyA's item.
    await expect(
      sysB.query(
        `SELECT record_movement($1, $2::bigint, '2024-03-01'::date, 1::numeric,
                                1::numeric, 'receipt', NULL, NULL, NULL)`,
        [db.companyA, fixtureItemA]),
    ).rejects.toThrow(/session company does not match/);
  });

  it("refuses a movement hung off an entry that is never posted (deferred, at COMMIT)", async () => {
    const bk = await newBooks("Dangling Co");
    const item = await stockItem(bk, "Dangler");
    await bk.client.query("BEGIN");
    const er = await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-01','never posted',$2) RETURNING id`,
      [bk.companyId, db.userA]);
    const entryId = Number(er.rows[0].id);
    // a perfectly good, balanced entry — it is simply never posted
    await bk.client.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'1.0000',0), ($1,$2,$4,0,'1.0000')`,
      [bk.companyId, entryId, bk.inventory, bk.ap]);
    await bk.client.query(
      `SELECT record_movement($1,$2::bigint,'2024-03-01'::date,1::numeric,
                              1::numeric,'receipt',NULL,NULL,$3::bigint)`,
      [bk.companyId, item, entryId]);
    await expect(bk.client.query("COMMIT")).rejects.toThrow(/which was never posted/);
    await bk.client.query("ROLLBACK").catch(() => {});
    expect(await qtyOnHand(bk, item)).toBe("0.0000");
  });

  it("refuses a movement dated differently from the entry it rides on", async () => {
    // If the two dates could differ, the two sides of the tie — filtered by
    // moved_on and entry_date respectively — would disagree on every date in
    // between. So the database refuses the pairing outright.
    const bk = await newBooks("Date Skew Co");
    const item = await stockItem(bk, "Skewed");
    await bk.client.query("BEGIN");
    const er = await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-05','skewed',$2) RETURNING id`, [bk.companyId, db.userA]);
    const entryId = Number(er.rows[0].id);
    await bk.client.query(
      `SELECT record_movement($1,$2::bigint,'2024-03-01'::date,4::numeric,
                              2::numeric,'receipt',NULL,NULL,$3::bigint)`,
      [bk.companyId, item, entryId]);
    await bk.client.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'8.0000',0), ($1,$2,$4,0,'8.0000')`,
      [bk.companyId, entryId, bk.inventory, bk.ap]);
    await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
    await expect(bk.client.query("COMMIT")).rejects.toThrow(
      /must share a date \(movement dated 2024-03-01, entry dated 2024-03-05\)/);
    await bk.client.query("ROLLBACK").catch(() => {});
    await expectTied(bk);
  });

  it("records source_kind / source_id and the entry it rode in on", async () => {
    const bk = await newBooks("Provenance Co");
    const item = await stockItem(bk, "Traceable");
    const m = await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "2", unitCost: "1.500000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
      sourceKind: "bill_line", sourceId: 4242,
    });
    const row = (await bk.client.query(
      `SELECT source_kind, source_id, entry_id, reason FROM inventory_movements
        WHERE company_id = $1 AND id = $2`, [bk.companyId, m.movementId])).rows[0];
    expect(row.source_kind).toBe("bill_line");
    expect(Number(row.source_id)).toBe(4242);
    expect(Number(row.entry_id)).toBe(m.entryId);
    expect(row.reason).toBe("receipt");
  });
});

// ===========================================================================
// NaN — numeric's trapdoor
// ===========================================================================

describe("non-finite quantities and costs are refused at BOTH doors", () => {
  it("record_movement refuses a NaN quantity and a NaN unit cost", async () => {
    // Postgres orders NaN ABOVE every real number and equal to itself, so
    // `on_hand + NaN < 0` is FALSE: without an explicit check, a NaN sails
    // through the negative-stock guard and every ordering CHECK on the table,
    // and then makes qty_on_hand / average_cost / the valuation report / the
    // tie NaN forever — with movement immutability sealing both repair paths.
    const bk = await newBooks("NaN Co");
    const item = await stockItem(bk, "Poison Pill");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "NaN", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/a stock movement has to change the quantity by something/);

    // the outbound door too — where a NaN would sail past the negative-stock
    // guard, since `10 + NaN < 0` is FALSE
    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "NaN", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    })).rejects.toThrow(/a stock movement has to change the quantity by something/);

    await expect(moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-02", qty: "5", unitCost: "NaN",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/a unit cost has to be a real number/);

    // untouched, and still finite
    expect(await qtyOnHand(bk, item)).toBe("10.0000");
    expect(await avgCost(bk, item)).toBe("1.000000");
    const t = await expectTied(bk);
    expect(t.movement_value).toBe("10.0000");
  });

  it("the table itself refuses a NaN qty, unit_cost or value — even from a superuser", async () => {
    // `x <> 'NaN'` is FALSE for NaN (NaN = NaN is true), so movements_finite
    // rejects it however the row arrives.
    const bk = await newBooks("NaN Direct Co");
    const item = await stockItem(bk, "Poison Pill");
    const entryId = await postedFixtureEntry(bk);   // entry_id is NOT NULL
    for (const [qty, unitCost, value] of [
      ["NaN", "1.000000", "1.0000"],
      ["1", "NaN", "1.0000"],
      ["1", "1.000000", "NaN"],
    ]) {
      await expect(db.admin.query(
        `INSERT INTO inventory_movements
           (company_id, item_id, moved_on, qty, unit_cost, value, reason, entry_id)
         VALUES ($1,$2,'2024-03-01',$3::numeric,$4::numeric,$5::numeric,'adjustment',$6)`,
        [bk.companyId, item, qty, unitCost, value, entryId]))
        .rejects.toThrow(/movements_finite|check constraint/);
    }
  });
});

// ===========================================================================
// The tie, made structural
// ===========================================================================

describe("the tie is structural: a movement and its entry must agree", () => {
  it("refuses an entry that posts the WRONG AMOUNT to the inventory account", async () => {
    const bk = await newBooks("Wrong Amount Co");
    const item = await stockItem(bk, "Widget");
    await bk.client.query("BEGIN");
    const entryId = Number((await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-01','understated receipt',$2) RETURNING id`,
      [bk.companyId, db.userA])).rows[0].id);
    await recordMovementRaw(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "5.000000",
      reason: "receipt", entryId,
    });                                     // movement value 50.0000
    await bk.client.query(                  // ...but the entry only posts 5
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'5.0000',0), ($1,$2,$4,0,'5.0000')`,
      [bk.companyId, entryId, bk.inventory, bk.ap]);
    await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
    await expect(bk.client.query("COMMIT")).rejects.toThrow(
      /moves 50\.0000 of stock through inventory account \d+, but posts 5\.0000 to it/);
    await bk.client.query("ROLLBACK").catch(() => {});
    await expectTied(bk);
  });

  it("refuses an entry that posts to the WRONG ACCOUNT entirely", async () => {
    const bk = await newBooks("Wrong Account Co");
    const item = await stockItem(bk, "Widget");
    await bk.client.query("BEGIN");
    const entryId = Number((await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-01','misfiled receipt',$2) RETURNING id`,
      [bk.companyId, db.userA])).rows[0].id);
    await recordMovementRaw(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "5.000000",
      reason: "receipt", entryId,
    });
    await bk.client.query(                  // Cash, not Inventory
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'50.0000',0), ($1,$2,$4,0,'50.0000')`,
      [bk.companyId, entryId, bk.cash, bk.ap]);
    await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
    await expect(bk.client.query("COMMIT")).rejects.toThrow(
      /moves 50\.0000 of stock through inventory account \d+, but posts 0\.0000 to it/);
    await bk.client.query("ROLLBACK").catch(() => {});
    await expectTied(bk);
  });

  it("refuses a movement with NO entry — for EVERY reason, with no exemption", async () => {
    // `reason` is a label the caller picks. An exemption keyed on it (an
    // earlier draft exempted build_in/build_out) is no rule at all: one
    // mislabelled call moves stock with no ledger counterpart, permanently,
    // because movements are immutable. So entry_id is simply NOT NULL.
    const bk = await newBooks("Bare Movement Co");
    const item = await stockItem(bk, "Widget");
    await moveAndPost(bk, {   // real stock, so the outbound cases get as far as the INSERT
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "5.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    for (const reason of ["receipt", "sale", "adjustment", "opening",
                          "build_in", "build_out"]) {
      const outbound = reason === "sale" || reason === "build_out";
      await expect(recordMovementRaw(bk, {
        itemId: item, movedOn: "2024-03-02", qty: outbound ? "-1" : "1",
        unitCost: outbound ? null : "5.000000", reason,
      })).rejects.toThrow(
        /null value in column "entry_id"|movements_entry_required|not-null constraint/);
    }
    expect(await qtyOnHand(bk, item)).toBe("10.0000");
    await expectTied(bk);
  });

  it("a same-account build posts a WASH entry rather than skipping the ledger", async () => {
    // Component and assembly share one inventory account, so nothing moves
    // between accounts — but the build still posts Dr <inventory> /
    // Cr <inventory> for the build value. Two lines, each with exactly one
    // non-zero side, summing to zero: legal under §3.1, and the build now
    // appears in the account register where an auditor can see it.
    const bk = await newBooks("Same Account Build Co");
    const component = await stockItem(bk, "Component");
    const assembly = await stockItem(bk, "Assembly");
    await moveAndPost(bk, {
      itemId: component, movedOn: "2024-03-01", qty: "10", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    let t = await expectTied(bk);
    expect(t.movement_value).toBe("20.0000");

    await bk.client.query("BEGIN");
    const entryId = Number((await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-05','Build #1 — 1 Assembly',$2) RETURNING id`,
      [bk.companyId, db.userA])).rows[0].id);
    const outId = await recordMovementRaw(bk, {
      itemId: component, movedOn: "2024-03-05", qty: "-5", reason: "build_out", entryId,
    });
    const inId = await recordMovementRaw(bk, {
      itemId: assembly, movedOn: "2024-03-05", qty: "1", unitCost: "10.000000",
      reason: "build_in", entryId,
    });
    await bk.client.query(   // the wash: both legs on the SAME account
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'10.0000',0), ($1,$2,$3,0,'10.0000')`,
      [bk.companyId, entryId, bk.inventory]);
    await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
    await bk.client.query("COMMIT");

    expect((await movementRow(bk, outId)).value).toBe("-10.0000");
    expect((await movementRow(bk, inId)).value).toBe("10.0000");
    expect(await qtyOnHand(bk, component)).toBe("5.0000");
    expect(await qtyOnHand(bk, assembly)).toBe("1.0000");
    expect(await avgCost(bk, assembly)).toBe("10.000000");
    t = await expectTied(bk);
    expect(t.movement_value).toBe("20.0000"); // -10 out, +10 in: net nil
    expect(t.ledger_value).toBe("20.0000");

    // and the build IS visible in the register — two lines, not silence
    const lines = await bk.client.query(
      "SELECT count(*)::int AS n FROM journal_lines WHERE company_id=$1 AND entry_id=$2",
      [bk.companyId, entryId]);
    expect(lines.rows[0].n).toBe(2);
  });

  it("movements cannot be appended to an entry by a LATER transaction", async () => {
    // The two movements below net to zero on the inventory account, so the
    // per-account amount check would wave them through — an unaudited stock
    // transfer between two items hung off an entry that is already posted and
    // immutable. Every movement on an entry must come from ONE transaction.
    const bk = await newBooks("Late Append Co");
    const a = await stockItem(bk, "Item A");
    const b = await stockItem(bk, "Item B");
    const receipt = await moveAndPost(bk, {
      itemId: a, movedOn: "2024-03-01", qty: "10", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await moveAndPost(bk, {
      itemId: b, movedOn: "2024-03-01", qty: "10", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await bk.client.query("BEGIN");
    await recordMovementRaw(bk, {
      itemId: a, movedOn: "2024-03-01", qty: "-4", reason: "adjustment",
      entryId: receipt.entryId,
    });
    await recordMovementRaw(bk, {
      itemId: b, movedOn: "2024-03-01", qty: "4", unitCost: "2.000000",
      reason: "adjustment", entryId: receipt.entryId,
    });
    await expect(bk.client.query("COMMIT")).rejects.toThrow(
      /already has stock movements from an earlier transaction — an entry and the stock it moved are recorded together, once/);
    await bk.client.query("ROLLBACK").catch(() => {});

    expect(await qtyOnHand(bk, a)).toBe("10.0000");
    expect(await qtyOnHand(bk, b)).toBe("10.0000");
    await expectTied(bk);
  });

  it("a period lock stops stock too, at record_movement's own door", async () => {
    const bk = await newBooks("Locked Stock Co");
    const component = await stockItem(bk, "Component");
    await moveAndPost(bk, {
      itemId: component, movedOn: "2024-03-01", qty: "10", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await bk.client.query(
      `INSERT INTO period_locks (company_id, locked_through, created_by)
       VALUES ($1,'2024-06-30',$2)`, [bk.companyId, db.userA]);

    // record_movement runs BEFORE post_entry in every document flow, so it
    // owns the check itself and fails fast, with post_entry's exact English.
    await expect(moveAndPost(bk, {
      itemId: component, movedOn: "2024-05-01", qty: "5", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    })).rejects.toThrow(/cannot post into locked period \(locked through 2024-06-30\)/);

    // and the app-facing adjustment door is bound by it too
    await expect(bk.client.query(
      "SELECT record_adjustment($1,$2,'2024-05-01',-1,NULL,$3,'shrinkage',$4)",
      [bk.companyId, component, bk.adjustment, db.userA]))
      .rejects.toThrow(/cannot post into locked period \(locked through 2024-06-30\)/);

    // after the lock, everything works normally again
    await moveAndPost(bk, {
      itemId: component, movedOn: "2024-07-01", qty: "5", unitCost: "2.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(await qtyOnHand(bk, component)).toBe("15.0000");
    await expectTied(bk);
  });

  it("reports a per-account tie, so offsetting errors on two accounts cannot hide", async () => {
    // A single aggregate would report a serene 0.0000 here: account 1300 is
    // short by 20 and 1310 is over by 20. The report is per-account precisely
    // so that cannot happen, and inventory_out_of_tie() sums ABSOLUTE
    // differences so the same cancellation cannot hide in the total either.
    const bk = await newBooks("Two Accounts Co");
    const inv2 = Number((await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type)
       VALUES ($1,'1310','Inventory - Finished','asset') RETURNING id`,
      [bk.companyId])).rows[0].id);
    const raw = await stockItem(bk, "Raw Part");
    const finished = await createItem(bk.client, bk.companyId, {
      name: "Finished Good", kind: "inventory",
      inventoryAccountId: inv2, cogsAccountId: bk.cogs,
    });
    await moveAndPost(bk, {
      itemId: raw, movedOn: "2024-03-01", qty: "10", unitCost: "18.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await moveAndPost(bk, {
      itemId: finished, movedOn: "2024-03-01", qty: "1", unitCost: "70.000000",
      reason: "receipt", debit: inv2, credit: bk.ap,
    });

    let rows = await tieRows(bk);
    expect(rows.map((r) => r.code)).toEqual(["1300", "1310"]);
    expect(rows[0]!.movement_value).toBe("180.0000");
    expect(rows[1]!.movement_value).toBe("70.0000");
    expect(await outOfTie(bk)).toBe("0.0000");

    // Now break BOTH accounts by exactly offsetting amounts — the classic
    // misposting between two inventory accounts, with a manual entry carrying
    // no movements at all.
    const misposted = async (): Promise<void> => {
      await bk.client.query("BEGIN");
      const entryId = Number((await bk.client.query(
        `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
         VALUES ($1,'2024-03-02','misposted transfer',$2) RETURNING id`,
        [bk.companyId, db.userA])).rows[0].id);
      await bk.client.query(
        `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
         VALUES ($1,$2,$3,'20.0000',0), ($1,$2,$4,0,'20.0000')`,
        [bk.companyId, entryId, bk.inventory, inv2]);
      await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
      await bk.client.query("COMMIT");
    };

    // 0022 refuses it outright now — a misposting between two inventory
    // accounts is exactly the shape check_entry_stock_backed() exists to stop,
    // and it is caught per account, so the offsetting halves cannot excuse
    // each other.
    await expect(misposted()).rejects.toThrow(/it is an inventory account/);
    await bk.client.query("ROLLBACK").catch(() => { /* already rolled back */ });
    expect(await outOfTie(bk)).toBe("0.0000");

    // ...and the REPORT still catches it if it ever gets in another way.
    await db.admin.query("ALTER TABLE journal_entries DISABLE TRIGGER entry_stock_backed");
    try {
      await misposted();
    } finally {
      await db.admin.query("ALTER TABLE journal_entries ENABLE TRIGGER entry_stock_backed");
    }

    rows = await tieRows(bk);
    expect(rows[0]!.ledger_value).toBe("200.0000");
    expect(rows[0]!.movement_value).toBe("180.0000");
    expect(rows[0]!.difference).toBe("20.0000");
    expect(rows[1]!.ledger_value).toBe("50.0000");
    expect(rows[1]!.movement_value).toBe("70.0000");
    expect(rows[1]!.difference).toBe("-20.0000");
    // the sum of the raw differences IS zero — which is exactly why the
    // single number is a sum of absolute values
    expect(await outOfTie(bk)).toBe("40.0000");
  });
});

// ===========================================================================
// record_movement is internal; record_adjustment is the door
// ===========================================================================

describe("the grant surface", () => {
  it("the app role cannot execute the internal routines", async () => {
    for (const call of [
      "SELECT record_movement($1, 1::bigint, '2024-03-01'::date, 1::numeric, 1::numeric, 'receipt', NULL, NULL, 1::bigint)",
      "SELECT allocate_po_no($1)",
      "SELECT allocate_build_no($1)",
    ]) {
      await expect(c.query(call, [db.companyA])).rejects.toThrow(/permission denied/i);
    }
  });

  it("the app role CAN execute the document-shaped and reporting functions", async () => {
    // Each of these owns both sides of whatever it writes, or writes nothing.
    // They may fail on their own terms — never with "permission denied".
    const calls: Array<[string, unknown[]]> = [
      ["SELECT item_qty_on_hand($1, $2)", [db.companyA, fixtureItemA]],
      ["SELECT item_average_cost($1, $2)", [db.companyA, fixtureItemA]],
      ["SELECT * FROM inventory_valuation($1, NULL)", [db.companyA]],
      ["SELECT * FROM check_inventory_ties($1, NULL)", [db.companyA]],
      ["SELECT inventory_out_of_tie($1, NULL)", [db.companyA]],
      ["SELECT assembly_would_recurse($1, $2, $2)", [db.companyA, fixtureItemA]],
      ["SELECT record_adjustment($1, $2, '2024-03-01', 1, 1, $3, 'probe', $4)",
       [db.companyA, fixtureItemA, cogsA, db.userA]],
    ];
    for (const [sql, params] of calls) {
      try {
        await c.query(sql, params);
      } catch (err) {
        expect((err as Error).message).not.toMatch(/permission denied/i);
      }
    }
  });
});

describe("record_adjustment", () => {
  it("writes the entry and the movement together, and the tie holds", async () => {
    const bk = await newBooks("Adjustment Co");
    const app = await appFor(bk);
    const item = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "20", unitCost: "4.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    // shrinkage: 3 gone at the running average of 4.00 = 12.0000
    const entryId = Number((await app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-3,NULL,$3,'annual count — 3 missing',$4) AS id",
      [bk.companyId, item, bk.adjustment, db.userA])).rows[0].id);
    expect(entryId).toBeGreaterThan(0);

    expect(await qtyOnHand(bk, item)).toBe("17.0000");
    expect(await avgCost(bk, item)).toBe("4.000000");
    expect(await accountBalance(bk, bk.inventory)).toBe("68.0000"); // 80 - 12
    expect(await accountBalance(bk, bk.adjustment)).toBe("12.0000");
    let t = await expectTied(bk);
    expect(t.movement_value).toBe("68.0000");

    // and stock found: 5 more at 4.00 = 20.0000
    await app.query(
      "SELECT record_adjustment($1,$2,'2024-03-06',5,4.000000,$3,'found in the back',$4)",
      [bk.companyId, item, bk.adjustment, db.userA]);
    expect(await qtyOnHand(bk, item)).toBe("22.0000");
    expect(await accountBalance(bk, bk.inventory)).toBe("88.0000");
    expect(await accountBalance(bk, bk.adjustment)).toBe("-8.0000"); // 12 - 20
    t = await expectTied(bk);
    expect(t.movement_value).toBe("88.0000");
    expect(t.ledger_value).toBe("88.0000");

    // the entry it posted is a real, posted, dated, memo'd entry
    const entry = (await bk.client.query(
      "SELECT status, entry_date, memo FROM journal_entries WHERE company_id=$1 AND id=$2",
      [bk.companyId, entryId])).rows[0];
    expect(entry.status).toBe("posted");
    expect(entry.memo).toBe("annual count — 3 missing");
  });

  it("insists on a reason, a real account, and never the inventory account itself", async () => {
    const bk = await newBooks("Adjustment Guards Co");
    const app = await appFor(bk);
    const item = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "20", unitCost: "4.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'   ',$4)",
      [bk.companyId, item, bk.adjustment, db.userA]))
      .rejects.toThrow(/say why the quantity is changing/);

    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrink',$4)",
      [bk.companyId, item, bk.inventory, db.userA]))
      .rejects.toThrow(/is this item's own inventory account/);

    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-999,NULL,$3,'shrink',$4)",
      [bk.companyId, item, bk.adjustment, db.userA]))
      .rejects.toThrow(/You only have 20 of Widget on hand — this would use 999\./);

    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,999999,'shrink',$3)",
      [bk.companyId, item, db.userA]))
      .rejects.toThrow(/account 999999 not found/);

    // nothing partial survived any of that
    expect(await qtyOnHand(bk, item)).toBe("20.0000");
    await expectTied(bk);
  });

  it("refuses ANOTHER item's inventory account, not just this item's own", async () => {
    // The hole the previous round left open. Item P lives in 1300, item F in
    // 1310. Posting P's shrinkage to 1310 breaks the tie on 1310 — and the
    // deferred per-entry check CANNOT see it, because it builds its account
    // set from the movements on the entry, and the only movement belongs to P.
    // A manufacturer with "Inventory - Raw Materials" and "Inventory -
    // Finished Goods" picks the wrong dropdown row and the books stop tying.
    const bk = await newBooks("Cross Account Adjustment Co");
    const app = await appFor(bk);
    const inv2 = Number((await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type)
       VALUES ($1,'1310','Inventory - Finished Goods','asset') RETURNING id`,
      [bk.companyId])).rows[0].id);
    const p = await stockItem(bk, "Raw Part");
    await createItem(bk.client, bk.companyId, {
      name: "Finished Good", kind: "inventory",
      inventoryAccountId: inv2, cogsAccountId: bk.cogs,
    });
    await moveAndPost(bk, {
      itemId: p, movedOn: "2024-03-01", qty: "10", unitCost: "5.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(await outOfTie(bk)).toBe("0.0000");

    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-10,NULL,$3,'count correction',$4)",
      [bk.companyId, p, inv2, db.userA])).rejects.toThrow(
      /'Inventory - Finished Goods' is another item's inventory account — pick the account that explains the change, like Shrinkage\./);

    // and this item's OWN account still gets its own sentence
    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-10,NULL,$3,'count correction',$4)",
      [bk.companyId, p, bk.inventory, db.userA])).rejects.toThrow(
      /'Inventory' is this item's own inventory account — pick the account that explains the change, like Shrinkage\./);

    // nothing leaked through either attempt
    expect(await qtyOnHand(bk, p)).toBe("10.0000");
    expect(await outOfTie(bk)).toBe("0.0000");
    const rows = await tieRows(bk);
    expect(rows.map((r) => [r.code, r.ledger_value, r.movement_value])).toEqual([
      ["1300", "50.0000", "50.0000"],
      ["1310", "0.0000", "0.0000"],
    ]);

    // ...and a real adjustment account works fine
    await app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-10,NULL,$3,'count correction',$4)",
      [bk.companyId, p, bk.adjustment, db.userA]);
    expect(await qtyOnHand(bk, p)).toBe("0.0000");
    expect(await outOfTie(bk)).toBe("0.0000");
  });

  it("insists the other side is an expense, income or equity account", async () => {
    const bk = await newBooks("Adjustment Account Type Co");
    const app = await appFor(bk);
    const item = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "20", unitCost: "4.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    const mk = async (code: string, name: string, type: string): Promise<number> =>
      Number((await db.admin.query(
        `INSERT INTO accounts (company_id, code, name, type) VALUES ($1,$2,$3,$4) RETURNING id`,
        [bk.companyId, code, name, type])).rows[0].id);
    const equity = await mk("3000", "Opening Balance Equity", "equity");
    const gain = await mk("4100", "Inventory Gain", "income");
    const ar = await mk("1100", "Accounts Receivable", "asset");
    const loan = await mk("2400", "Loan Payable", "liability");

    // Shrinkage posted to Cash silently misstates the bank balance and
    // corrupts the next reconciliation; to A/R it corrupts the aging.
    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrink',$4)",
      [bk.companyId, item, bk.cash, db.userA])).rejects.toThrow(
      /an inventory adjustment posts to an expense, income or equity account — 'Cash' is an asset account/);
    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrink',$4)",
      [bk.companyId, item, ar, db.userA])).rejects.toThrow(/is an asset account/);
    await expect(app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrink',$4)",
      [bk.companyId, item, loan, db.userA])).rejects.toThrow(
      /'Loan Payable' is a liability account/);

    // expense (shrinkage), income (a found-inventory gain) and equity (the
    // spec's opening-quantities path, Dr Inventory / Cr Opening Balance
    // Equity) are all legitimate.
    await app.query("SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrinkage',$4)",
      [bk.companyId, item, bk.adjustment, db.userA]);
    await app.query("SELECT record_adjustment($1,$2,'2024-03-06',2,4.000000,$3,'found',$4)",
      [bk.companyId, item, gain, db.userA]);
    await app.query("SELECT record_adjustment($1,$2,'2024-03-07',5,4.000000,$3,'opening count',$4)",
      [bk.companyId, item, equity, db.userA]);

    expect(await qtyOnHand(bk, item)).toBe("26.0000");
    expect(await accountBalance(bk, equity)).toBe("-20.0000");
    expect(await accountBalance(bk, gain)).toBe("-8.0000");
    expect(await outOfTie(bk)).toBe("0.0000");
  });

  it("records what was STORED in the audit log, not what the caller typed", async () => {
    // record_movement rounds the quantity to the column's scale; the audit log
    // has to say what actually happened.
    const bk = await newBooks("Audit Detail Co");
    const app = await appFor(bk);
    const item = await stockItem(bk, "Bulk Powder");
    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await app.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1.00005,NULL,$3,'spillage',$4)",
      [bk.companyId, item, bk.adjustment, db.userA]);

    const detail = (await bk.client.query(
      `SELECT detail FROM audit_log
        WHERE company_id=$1 AND action='adjust' ORDER BY id DESC LIMIT 1`,
      [bk.companyId])).rows[0].detail;
    // exact strings, not JSON floats (CLAUDE.md §2)
    expect(detail.qty).toBe("-1.0001");     // stored, not the -1.00005 passed in
    expect(detail.value).toBe("-1.0001");
    const stored = (await bk.client.query(
      "SELECT qty FROM inventory_movements WHERE company_id=$1 AND id=$2",
      [bk.companyId, Number(detail.movement_id)])).rows[0].qty;
    expect(stored).toBe(detail.qty);
    expect(await outOfTie(bk)).toBe("0.0000");
  });

  it("refuses another company's session", async () => {
    await expect(cB.query(
      "SELECT record_adjustment($1,$2,'2024-03-05',-1,NULL,$3,'shrink',$4)",
      [db.companyA, fixtureItemA, cogsA, db.userA]))
      .rejects.toThrow(/session company does not match/);
  });
});

// ===========================================================================
// reverse_entry cannot unpost stock
// ===========================================================================

describe("reverse_entry and inventory", () => {
  it("refuses to reverse an entry that moved stock, and leaves everything intact", async () => {
    // reverse_entry() is app-executable and exposed at
    // POST /api/c/entries/:id/reverse. Left alone it would write the mirror
    // image straight out of the Inventory account while the quantity on hand
    // stayed exactly where it was — and the break would be UNRECOVERABLE,
    // because restoring the tie would mean destroying stock that is still
    // physically on the shelf.
    const bk = await newBooks("Reverse Co");
    const item = await stockItem(bk, "Widget");
    const receipt = await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "10", unitCost: "5.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    await expect(bk.client.query(
      "SELECT reverse_entry($1,$2,'2024-03-10',$3)",
      [bk.companyId, receipt.entryId, db.userA])).rejects.toThrow(
      /entry \d+ moved stock, so reversing it on its own would leave the quantity on hand wrong — void the document it came from, or record an inventory adjustment instead/);

    expect(await qtyOnHand(bk, item)).toBe("10.0000");
    const t = await expectTied(bk);
    expect(t.ledger_value).toBe("50.0000");
    expect(t.movement_value).toBe("50.0000");
  });

  it("still reverses an ordinary entry that moved no stock (no regression)", async () => {
    const bk = await newBooks("Plain Reverse Co");
    await bk.client.query("BEGIN");
    const entryId = Number((await bk.client.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-01','office supplies',$2) RETURNING id`,
      [bk.companyId, db.userA])).rows[0].id);
    await bk.client.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'75.0000',0), ($1,$2,$4,0,'75.0000')`,
      [bk.companyId, entryId, bk.adjustment, bk.cash]);
    await bk.client.query("SELECT post_entry($1,$2)", [bk.companyId, entryId]);
    await bk.client.query("COMMIT");

    const rev = await bk.client.query(
      "SELECT reverse_entry($1,$2,'2024-03-10',$3) AS id",
      [bk.companyId, entryId, db.userA]);
    expect(Number(rev.rows[0].id)).toBeGreaterThan(0);
    expect(await accountBalance(bk, bk.cash)).toBe("0.0000");
    const oob = await bk.client.query("SELECT ledger_out_of_balance($1) AS d", [bk.companyId]);
    expect(oob.rows[0].d).toBe("0.0000");
  });
});

// ===========================================================================
// Concurrency — the item lock is what makes average cost trustworthy
// ===========================================================================

describe("concurrent issues serialize on the item row", () => {
  it("two app connections issuing the same item cannot drift — each sees the other's effect", async () => {
    // Hand-computed, and chosen so a RACE would give a visibly different
    // answer than SERIALIZATION:
    //   receipt +3 @ 0.333333 -> value round(0.999999, 4) = 1.0000, average 0.333333
    //   A issues -1 at 0.333333 -> value -0.3333 ; left: qty 2, value 0.6667
    //   B issues -1 at 0.6667/2 = 0.333350 -> value round(-0.33335,4) = -0.3334
    //   final: qty 1, value 0.3333
    // If the two had raced, B would also have applied 0.333333, leaving
    // value 0.3334 and two identical unit costs.
    const bk = await newBooks("Race Co");
    const bearing = await stockItem(bk, "Bearing");
    const receipt = await moveAndPost(bk, {
      itemId: bearing, movedOn: "2024-03-01", qty: "3", unitCost: "0.333333",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    expect(receipt.value).toBe("1.0000");
    expect(await avgCost(bk, bearing)).toBe("0.333333");

    // record_movement is internal, so these stand in for two concurrent
    // SECURITY DEFINER document calls — two independent connections either way,
    // which is what the FOR UPDATE serialization is being tested on.
    const a = await db.sysClient(bk.companyId);
    const b = await db.sysClient(bk.companyId);
    const booksA: Books = { ...bk, client: a };
    const booksB: Books = { ...bk, client: b };

    await a.query("BEGIN");
    const erA = await a.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-02','A',$2) RETURNING id`, [bk.companyId, db.userA]);
    const entryA = Number(erA.rows[0].id);
    const mrA = await a.query(
      `SELECT record_movement($1,$2::bigint,'2024-03-02'::date,-1::numeric,
                              NULL::numeric,'sale',NULL,NULL,$3::bigint) AS id`,
      [bk.companyId, bearing, entryA]);
    const movA = Number(mrA.rows[0].id);
    const valA: string = (await a.query(
      "SELECT value FROM inventory_movements WHERE company_id=$1 AND id=$2",
      [bk.companyId, movA])).rows[0].value;
    await a.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,$4,0), ($1,$2,$5,0,$4)`,
      [bk.companyId, entryA, bk.cogs, valA.slice(1), bk.inventory]);
    await a.query("SELECT post_entry($1,$2)", [bk.companyId, entryA]);

    // B now races the same item. record_movement takes the item lock FIRST,
    // so B blocks here until A commits — it cannot read a stale average.
    const pendingB = inFlight(moveAndPost(booksB, {
      itemId: bearing, movedOn: "2024-03-02", qty: "-1", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    }));
    await new Promise((r) => setTimeout(r, 300));
    // Still waiting on the item lock. If B had already finished it would have
    // read A's pre-commit average, and the unit costs asserted below would be
    // proving the opposite of what this test claims.
    pendingB.stillBlocked("B's record_movement");
    await a.query("COMMIT");
    const mB = await pendingB.settled();

    expect(valA).toBe("-0.3333");
    expect(mB.value).toBe("-0.3334");
    expect(mB.unitCost).toBe("0.333350");
    expect(mB.unitCost).not.toBe("0.333333"); // proof B did not reuse A's average

    expect(await qtyOnHand(booksA, bearing)).toBe("1.0000");
    const t = await expectTied(booksA);
    expect(t.movement_value).toBe("0.3333"); // 1.0000 - 0.3333 - 0.3334
    expect(t.ledger_value).toBe("0.3333");
  });

  it("the second of two concurrent issues is refused when the first drained the stock", async () => {
    const bk = await newBooks("Oversell Race Co");
    const widget = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "100", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    const a = await db.sysClient(bk.companyId);
    const b = await db.sysClient(bk.companyId);

    await a.query("BEGIN");
    const erA = await a.query(
      `INSERT INTO journal_entries (company_id, entry_date, memo, created_by)
       VALUES ($1,'2024-03-02','A',$2) RETURNING id`, [bk.companyId, db.userA]);
    const entryA = Number(erA.rows[0].id);
    await a.query(
      `SELECT record_movement($1,$2::bigint,'2024-03-02'::date,-60::numeric,
                              NULL::numeric,'sale',NULL,NULL,$3::bigint)`,
      [bk.companyId, widget, entryA]);
    await a.query(
      `INSERT INTO journal_lines (company_id, entry_id, account_id, debit, credit)
       VALUES ($1,$2,$3,'60.0000',0), ($1,$2,$4,0,'60.0000')`,
      [bk.companyId, entryA, bk.cogs, bk.inventory]);
    await a.query("SELECT post_entry($1,$2)", [bk.companyId, entryA]);

    const pendingB = inFlight(moveAndPost({ ...bk, client: b }, {
      itemId: widget, movedOn: "2024-03-02", qty: "-60", reason: "sale",
      debit: bk.cogs, credit: bk.inventory,
    }));
    await new Promise((r) => setTimeout(r, 300));
    // Still waiting on the item lock — a B that had already run would have
    // seen the full 100 and succeeded, overselling the item.
    pendingB.stillBlocked("B's record_movement");
    await a.query("COMMIT");

    // B was serialized behind A, so it sees 40 left — not the 100 it would
    // have seen had it read the average and the quantity concurrently.
    await expect(pendingB.settled()).rejects.toThrow(
      /You only have 40 of Widget on hand — this would use 60\./);

    expect(await qtyOnHand({ ...bk, client: a }, widget)).toBe("40.0000");
    const t = await expectTied({ ...bk, client: a });
    expect(t.movement_value).toBe("40.0000");
  });
});

// ===========================================================================
// Movements are a permanent record
// ===========================================================================

describe("inventory_movements immutability", () => {
  it("UPDATE is refused for the app role AND for the superuser", async () => {
    await expect(c.query(
      "UPDATE inventory_movements SET qty = '999' WHERE company_id = $1 AND id = $2",
      [db.companyA, fixtureMovementA])).rejects.toThrow(
      /stock movements are a permanent record/);
    await expect(db.admin.query(
      "UPDATE inventory_movements SET qty = '999' WHERE id = $1",
      [fixtureMovementA])).rejects.toThrow(/stock movements are a permanent record/);
  });

  it("DELETE is refused for the app role AND for the superuser", async () => {
    await expect(c.query(
      "DELETE FROM inventory_movements WHERE company_id = $1 AND id = $2",
      [db.companyA, fixtureMovementA])).rejects.toThrow(
      /stock movements are a permanent record/);
    await expect(db.admin.query(
      "DELETE FROM inventory_movements WHERE id = $1",
      [fixtureMovementA])).rejects.toThrow(/stock movements are a permanent record/);
  });

  it("TRUNCATE is refused even for the superuser", async () => {
    await expect(db.admin.query("TRUNCATE inventory_movements"))
      .rejects.toThrow(/TRUNCATE is not allowed/);
    await expect(db.admin.query("TRUNCATE items CASCADE"))
      .rejects.toThrow(/TRUNCATE is not allowed/);
  });

  it("the app role cannot INSERT a movement directly — record_movement is the only door", async () => {
    await expect(c.query(
      `INSERT INTO inventory_movements
         (company_id, item_id, moved_on, qty, unit_cost, value, reason)
       VALUES ($1,$2,'2024-01-05','5','1.000000','5.0000','receipt')`,
      [db.companyA, fixtureItemA])).rejects.toThrow(/permission denied/i);
  });

  it("the movement survived every one of those attempts unchanged", async () => {
    const row = (await c.query(
      "SELECT qty, unit_cost, value FROM inventory_movements WHERE company_id=$1 AND id=$2",
      [db.companyA, fixtureMovementA])).rows[0];
    expect(row.qty).toBe("10.0000");
    expect(row.unit_cost).toBe("1.000000");
    expect(row.value).toBe("10.0000");
  });
});

// ===========================================================================
// §3.4 — nothing derived is ever stored
// ===========================================================================

describe("no stored quantities or values (CLAUDE.md §3.4)", () => {
  it("items has no quantity, no value, and no average-cost column", async () => {
    const cols = (await db.admin.query(
      `SELECT column_name FROM information_schema.columns
        WHERE table_schema = 'public' AND table_name = 'items'`))
      .rows.map((r: { column_name: string }) => r.column_name);
    expect(cols).toContain("kind"); // non-vacuous: we really are reading items
    for (const forbidden of ["quantity", "qty", "qty_on_hand", "on_hand",
                             "value", "average_cost", "avg_cost", "total_value"]) {
      expect(cols).not.toContain(forbidden);
    }
  });

  it("purchase_order_lines has no qty_received column — receipts are summed at query time", async () => {
    const cols = (await db.admin.query(
      `SELECT column_name FROM information_schema.columns
        WHERE table_schema = 'public' AND table_name = 'purchase_order_lines'`))
      .rows.map((r: { column_name: string }) => r.column_name);
    expect(cols).toContain("qty_ordered");
    expect(cols).not.toContain("qty_received");
  });
});

// ===========================================================================
// items — kinds and account typing
// ===========================================================================

describe("items", () => {
  it("an inventory item must name an ASSET inventory account, refused in English otherwise", async () => {
    await expect(createItem(c, db.companyA, {
      name: "Wrongly Accounted Item", kind: "inventory",
      inventoryAccountId: db.accounts["6000"].A, cogsAccountId: cogsA,
    })).rejects.toThrow(
      /the inventory account for 'Wrongly Accounted Item' must be an asset account \(account \d+ is expense\)/);
  });

  it("the cost account must be an expense account and the income account an income account", async () => {
    await expect(createItem(c, db.companyA, {
      name: "Bad Cost Account Item", kind: "inventory",
      inventoryAccountId: invA, cogsAccountId: db.accounts["4000"].A,
    })).rejects.toThrow(/the cost account for 'Bad Cost Account Item' must be an expense account/);

    await expect(createItem(c, db.companyA, {
      name: "Bad Income Account Item", kind: "inventory",
      inventoryAccountId: invA, cogsAccountId: cogsA,
      incomeAccountId: db.accounts["6000"].A,
    })).rejects.toThrow(/the income account for 'Bad Income Account Item' must be an income account/);
  });

  it("an inventory account is required for, and only for, a stocked item", async () => {
    await expect(createItem(c, db.companyA, {
      name: "Stocked But Unaccounted", kind: "inventory", cogsAccountId: cogsA,
    })).rejects.toThrow(/items_inventory_account_iff_inventory|check constraint/);

    await expect(createItem(c, db.companyA, {
      name: "Service With Inventory Account", kind: "service",
      inventoryAccountId: invA,
    })).rejects.toThrow(/items_inventory_account_iff_inventory|check constraint/);

    await expect(createItem(c, db.companyA, {
      name: "Non Inventory With Inventory Account", kind: "non_inventory",
      inventoryAccountId: invA, cogsAccountId: cogsA,
    })).rejects.toThrow(/items_inventory_account_iff_inventory|check constraint/);
  });

  it("anything with a cost of sale needs a cost account; a service does not", async () => {
    await expect(createItem(c, db.companyA, {
      name: "Uncosted Non Inventory", kind: "non_inventory",
    })).rejects.toThrow(/items_cogs_account_required|check constraint/);

    await expect(createItem(c, db.companyA, {
      name: "Plain Service", kind: "service", incomeAccountId: db.accounts["4000"].A,
    })).resolves.toBeGreaterThan(0);
  });

  it("an unknown kind is refused", async () => {
    await expect(createItem(c, db.companyA, {
      name: "Weird Kind", kind: "widgetish", inventoryAccountId: invA, cogsAccountId: cogsA,
    })).rejects.toThrow(/check constraint/);
  });

  it("names are unique per company (not across), and SKUs are unique when present", async () => {
    await createItem(c, db.companyA, {
      name: "Shared Name Item", inventoryAccountId: invA, cogsAccountId: cogsA, sku: "SKU-1",
    });
    await expect(createItem(c, db.companyA, {
      name: "Shared Name Item", inventoryAccountId: invA, cogsAccountId: cogsA,
    })).rejects.toThrow(/duplicate key|unique/);
    await expect(createItem(c, db.companyA, {
      name: "Other Name Item", inventoryAccountId: invA, cogsAccountId: cogsA, sku: "SKU-1",
    })).rejects.toThrow(/duplicate key|unique/);

    // several items may have no SKU at all
    await createItem(c, db.companyA, {
      name: "No Sku One", inventoryAccountId: invA, cogsAccountId: cogsA,
    });
    await expect(createItem(c, db.companyA, {
      name: "No Sku Two", inventoryAccountId: invA, cogsAccountId: cogsA,
    })).resolves.toBeGreaterThan(0);

    // the same name and SKU are fine in a different company
    await expect(createItem(cB, db.companyB, {
      name: "Shared Name Item", inventoryAccountId: invB, cogsAccountId: cogsB, sku: "SKU-1",
    })).resolves.toBeGreaterThan(0);
  });

  it("an item cannot point at another company's account", async () => {
    // The account-typing trigger reads `accounts` through the caller's own RLS
    // scope, so company B's account is simply not there — refused in English
    // before the composite FK (which would refuse it too) is ever consulted.
    await expect(createItem(c, db.companyA, {
      name: "Cross Company Item", inventoryAccountId: invB, cogsAccountId: cogsA,
    })).rejects.toThrow(new RegExp(`account ${invB} not found`));
    // The same guard binds a superuser (the account lookup is keyed by
    // (company_id, id), so another company's account is simply not there).
    await expect(db.admin.query(
      `INSERT INTO items (company_id, name, kind, inventory_account_id, cogs_account_id)
       VALUES ($1,'Cross Company Item (fk)','inventory',$2,$3)`,
      [db.companyA, invB, cogsA])).rejects.toThrow(new RegExp(`account ${invB} not found`));

    // and the composite FKs stand behind it, so a cross-company reference is
    // physically impossible even if that trigger were ever dropped (§4).
    const fks = (await db.admin.query(
      `SELECT conname, pg_get_constraintdef(oid) AS def FROM pg_constraint
        WHERE conrelid = 'items'::regclass AND contype = 'f'`)).rows
      .map((r: { def: string }) => r.def.replace(/\s+/g, " "));
    for (const col of ["inventory_account_id", "cogs_account_id", "income_account_id"]) {
      expect(fks).toContain(
        `FOREIGN KEY (company_id, ${col}) REFERENCES accounts(company_id, id)`);
    }
  });

  it("kind and inventory account freeze once the item has stock history", async () => {
    const bk = await newBooks("Frozen Item Co");
    const item = await stockItem(bk, "Committed Part");
    const otherAsset = Number((await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type)
       VALUES ($1,'1310','Other Inventory','asset') RETURNING id`, [bk.companyId])).rows[0].id);

    // before any movement, both are freely editable
    await bk.client.query(
      "UPDATE items SET inventory_account_id = $1 WHERE company_id = $2 AND id = $3",
      [otherAsset, bk.companyId, item]);
    await bk.client.query(
      "UPDATE items SET inventory_account_id = $1 WHERE company_id = $2 AND id = $3",
      [bk.inventory, bk.companyId, item]);

    await moveAndPost(bk, {
      itemId: item, movedOn: "2024-03-01", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });

    // Re-pointing the inventory account now would silently break the tie
    // forever, because the movements already posted to the OLD account and
    // movements can never be edited.
    await expect(bk.client.query(
      "UPDATE items SET inventory_account_id = $1 WHERE company_id = $2 AND id = $3",
      [otherAsset, bk.companyId, item])).rejects.toThrow(
      /'Committed Part' already has stock history — its item type and inventory account cannot be changed/);
    await expect(bk.client.query(
      "UPDATE items SET kind = 'service', inventory_account_id = NULL WHERE company_id = $1 AND id = $2",
      [bk.companyId, item])).rejects.toThrow(/already has stock history/);

    // everything else about the item stays editable
    await bk.client.query(
      "UPDATE items SET name = 'Committed Part (renamed)', sales_price = '9.9900' WHERE company_id=$1 AND id=$2",
      [bk.companyId, item]);
    await expectTied(bk);
  });

  it("an item with stock history cannot be deleted, in English", async () => {
    const bk = await newBooks("Undeletable Co");
    const spare = await stockItem(bk, "Spare Item");
    await bk.client.query("DELETE FROM items WHERE company_id=$1 AND id=$2",
      [bk.companyId, spare]); // never moved — fine

    const used = await stockItem(bk, "Used Item");
    await moveAndPost(bk, {
      itemId: used, movedOn: "2024-03-01", qty: "1", unitCost: "1.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    await expect(bk.client.query("DELETE FROM items WHERE company_id=$1 AND id=$2",
      [bk.companyId, used])).rejects.toThrow(
      /'Used Item' has stock history and cannot be deleted — mark it inactive instead/);
  });
});

// ===========================================================================
// Bills of materials
// ===========================================================================

describe("assemblies (bill of materials)", () => {
  // One set of books for the whole block: nothing here posts anything, so
  // there are no absolute figures to keep apart (and one connection is
  // cheaper than five).
  let bk: Required<Books>;
  beforeAll(async () => { bk = await newBooks("BOM Co"); }, 30_000);

  it("an item cannot be a part of itself", async () => {
    const a = await stockItem(bk, "Self Widget");
    // The trigger speaks first (in English); the CHECK constraint stands
    // behind it as the constraint that cannot be bypassed at all.
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$2,'1')`, [bk.companyId, a]))
      .rejects.toThrow(/'Self Widget' cannot be a part of itself/);
    await expect(db.admin.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$2,'1')`, [bk.companyId, a]))
      .rejects.toThrow(/cannot be a part of itself|assemblies_not_self/);
    const r = await bk.client.query(
      "SELECT assembly_would_recurse($1,$2,$2) AS r", [bk.companyId, a]);
    expect(r.rows[0].r).toBe(true);
  });

  it("refuses a BOM that would make an item its own ancestor (A -> B -> A)", async () => {
    const a = await stockItem(bk, "Widget A");
    const b = await stockItem(bk, "Widget B");
    await bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'2')`, [bk.companyId, a, b]);

    expect((await bk.client.query(
      "SELECT assembly_would_recurse($1,$2,$3) AS r", [bk.companyId, b, a])).rows[0].r).toBe(true);
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [bk.companyId, b, a]))
      .rejects.toThrow(/'Widget A' is already made from 'Widget B' — adding it back would make the item a part of itself/);
  });

  it("refuses a deeper cycle (A -> B -> C -> A) and allows a legitimate deep tree", async () => {
    const a = await stockItem(bk, "Level A");
    const b = await stockItem(bk, "Level B");
    const cItem = await stockItem(bk, "Level C");
    const d = await stockItem(bk, "Level D");
    const add = (asm: number, comp: number) => bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [bk.companyId, asm, comp]);

    await add(a, b);
    await add(b, cItem);
    await add(cItem, d);          // A -> B -> C -> D, four levels, all fine
    await expect(add(d, a)).rejects.toThrow(/would make the item a part of itself/);
    expect((await bk.client.query(
      "SELECT assembly_would_recurse($1,$2,$3) AS r", [bk.companyId, d, a])).rows[0].r).toBe(true);
    // a diamond is NOT a cycle: D may also be a direct part of A
    await expect(add(a, d)).resolves.toBeTruthy();
  });

  it("only stocked items can be assemblies or components", async () => {
    const asm = await stockItem(bk, "Finished Good");
    const svc = await createItem(bk.client, bk.companyId, {
      name: "Assembly Labor", kind: "service", incomeAccountId: bk.income,
    });
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [bk.companyId, asm, svc]))
      .rejects.toThrow(/'Assembly Labor' is not a stocked item, so it cannot be a part of 'Finished Good'/);
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [bk.companyId, svc, asm]))
      .rejects.toThrow(/only a stocked item can be built from parts — 'Assembly Labor' is not one/);
  });

  it("qty_per must be positive and a pair appears at most once", async () => {
    const a = await stockItem(bk, "Parent");
    const b = await stockItem(bk, "Child");
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'0')`, [bk.companyId, a, b])).rejects.toThrow(/check constraint/);
    await bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'2.5')`, [bk.companyId, a, b]);
    await expect(bk.client.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [bk.companyId, a, b])).rejects.toThrow(/duplicate key|unique/);
  });
});

// ===========================================================================
// Purchase orders — a promise, not a transaction
// ===========================================================================

describe("purchase orders", () => {
  it("issuing a PO changes no balance, posts no entry and records no movement", async () => {
    const bk = await newBooks("PO Co");
    const widget = await stockItem(bk, "Widget");
    await moveAndPost(bk, {
      itemId: widget, movedOn: "2024-03-01", qty: "5", unitCost: "10.000000",
      reason: "receipt", debit: bk.inventory, credit: bk.ap,
    });
    const before = {
      tie: await expectTied(bk),
      inventory: await accountBalance(bk, bk.inventory),
      ap: await accountBalance(bk, bk.ap),
      entries: (await bk.client.query(
        "SELECT count(*)::int AS n FROM journal_entries WHERE company_id=$1", [bk.companyId])).rows[0].n,
      movements: (await bk.client.query(
        "SELECT count(*)::int AS n FROM inventory_movements WHERE company_id=$1", [bk.companyId])).rows[0].n,
      qty: await qtyOnHand(bk, widget),
    };

    const vendor = await createVendor(bk.client, bk.companyId, "Parts Supplier");
    const po = await draftPo(bk, vendor, [
      { itemId: widget, qty: "100", unitCost: "9.750000" },
      { description: "Freight", qty: "1", unitCost: "45.000000" },
    ]);
    const poNo = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po, db.userA])).rows[0].n);
    expect(poNo).toBe(1);

    expect(await accountBalance(bk, bk.inventory)).toBe(before.inventory);
    expect(await accountBalance(bk, bk.ap)).toBe(before.ap);
    expect((await bk.client.query(
      "SELECT count(*)::int AS n FROM journal_entries WHERE company_id=$1",
      [bk.companyId])).rows[0].n).toBe(before.entries);
    expect((await bk.client.query(
      "SELECT count(*)::int AS n FROM inventory_movements WHERE company_id=$1",
      [bk.companyId])).rows[0].n).toBe(before.movements);
    expect(await qtyOnHand(bk, widget)).toBe(before.qty);
    const after = await expectTied(bk);
    expect(after.ledger_value).toBe(before.tie.ledger_value);
    expect(after.movement_value).toBe(before.tie.movement_value);
  });

  it("POs are born drafts, need at least one line, and cannot be issued twice", async () => {
    const bk = await sharedPoBooks();
    const vendor = await createVendor(bk.client, bk.companyId, "Lifecycle Supplier");

    await expect(bk.client.query(
      `INSERT INTO purchase_orders (company_id, vendor_id, order_date, status, created_by)
       VALUES ($1,$2,'2024-02-01','open',$3)`, [bk.companyId, vendor, db.userA]))
      .rejects.toThrow(/purchase orders are created as drafts; use issue_purchase_order\(\) to issue/);

    const empty = await draftPo(bk, vendor, []);
    await expect(bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, empty, db.userA]))
      .rejects.toThrow(/has nothing on it/);

    const item = await stockItem(bk, "Orderable");
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "2", unitCost: "1.000000" }]);
    await bk.client.query("SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, po, db.userA]);
    await expect(bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, po, db.userA]))
      .rejects.toThrow(/is already open/);
  });

  it("a PO line must say what it is for, and cannot reference another company's item", async () => {
    const bk = await sharedPoBooks();
    const vendor = await createVendor(bk.client, bk.companyId, "Line Supplier");
    const po = await draftPo(bk, vendor, []);
    await expect(bk.client.query(
      `INSERT INTO purchase_order_lines (company_id, po_id, qty_ordered, unit_cost)
       VALUES ($1,$2,'1','1')`, [bk.companyId, po])).rejects.toThrow(/check constraint/);
    await expect(bk.client.query(
      `INSERT INTO purchase_order_lines (company_id, po_id, item_id, qty_ordered, unit_cost)
       VALUES ($1,$2,$3,'1','1')`, [bk.companyId, po, fixtureItemA]))
      .rejects.toThrow(/foreign key|violates/);
  });

  it("a bill line may only claim to fulfil a PO line if it names an item", async () => {
    const cols = (await db.admin.query(
      `SELECT column_name FROM information_schema.columns
        WHERE table_schema='public' AND table_name='bill_lines'`))
      .rows.map((r: { column_name: string }) => r.column_name);
    expect(cols).toContain("item_id");
    expect(cols).toContain("po_line_id");
    expect(cols).toContain("quantity");   // reused, not duplicated

    const invCols = (await db.admin.query(
      `SELECT column_name FROM information_schema.columns
        WHERE table_schema='public' AND table_name='invoice_lines'`))
      .rows.map((r: { column_name: string }) => r.column_name);
    expect(invCols).toContain("item_id");
    expect(invCols).toContain("quantity");

    const bk = await sharedPoBooks();
    const vendor = await createVendor(bk.client, bk.companyId, "Bill Line Supplier");
    const item = await stockItem(bk, "Receivable Part");
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "10", unitCost: "3.000000" }]);
    // Task 2 (db/migrations/0019_inventory_receiving.sql) refuses a bill line
    // that fulfils a purchase order the vendor was never sent, so the fixture
    // issues it. Nothing about what this test asserts changes: issuing a PO
    // still posts nothing at all (asserted at length elsewhere in this file).
    await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, po, db.userA]);
    const poLine = Number((await bk.client.query(
      "SELECT id FROM purchase_order_lines WHERE company_id=$1 AND po_id=$2",
      [bk.companyId, po])).rows[0].id);
    const bill = Number((await bk.client.query(
      `INSERT INTO bills (company_id, vendor_id, bill_date, due_date, created_by)
       VALUES ($1,$2,'2024-03-01','2024-03-31',$3) RETURNING id`,
      [bk.companyId, vendor, db.userA])).rows[0].id);

    // The category account is deliberately NOT the inventory account here:
    // 0019 refuses a line coded straight to an inventory account, and a BEFORE
    // trigger runs ahead of a CHECK, so using bk.inventory would test that
    // trigger instead of the constraint this test is about.
    await expect(bk.client.query(
      `INSERT INTO bill_lines (company_id, bill_id, quantity, unit_price, amount,
                               category_account_id, po_line_id)
       VALUES ($1,$2,'10','3.0000','30.0000',$3,$4)`,
      [bk.companyId, bill, bk.cogs, poLine]))
      .rejects.toThrow(/bill_lines_po_line_requires_item|check constraint/);

    await expect(bk.client.query(
      `INSERT INTO bill_lines (company_id, bill_id, quantity, unit_price, amount,
                               category_account_id, item_id, po_line_id)
       VALUES ($1,$2,'10','3.0000','30.0000',$3,$4,$5)`,
      [bk.companyId, bill, bk.inventory, item, poLine])).resolves.toBeTruthy();
  });
});

describe("purchase orders only ever run forwards", () => {
  let bk: Required<Books>;
  let vendor = 0;
  let item = 0;
  beforeAll(async () => {
    bk = await newBooks("PO Forward Co");
    vendor = await createVendor(bk.client, bk.companyId, "Supplier");
    item = await stockItem(bk, "Ordered Thing");
  }, 30_000);

  it("an issued PO cannot be pushed back to draft, and cannot change its number", async () => {
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    const no = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po, db.userA])).rows[0].n);

    // Flipping back to draft would keep the number AND free the PO to be
    // issued again, burning the next one and leaving two POs able to claim
    // this one.
    await expect(bk.client.query(
      "UPDATE purchase_orders SET status='draft' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rejects.toThrow(/a purchase order cannot go from open back to draft/);
    // po_no is not in the app's UPDATE column grant at all, so the app role
    // never even reaches the trigger...
    await expect((await appFor(bk)).query(
      "UPDATE purchase_orders SET po_no = 999 WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rejects.toThrow(/permission denied/i);
    // ...and the trigger behind it binds everyone else, superuser included.
    await expect(db.admin.query(
      "UPDATE purchase_orders SET po_no = 999 WHERE id=$1",
      [po])).rejects.toThrow(new RegExp(`purchase order #${no} keeps its number`));
    await expect(db.admin.query(
      "UPDATE purchase_orders SET po_no = NULL WHERE id=$1",
      [po])).rejects.toThrow(/keeps its number/);

    const row = (await bk.client.query(
      "SELECT status, po_no FROM purchase_orders WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rows[0];
    expect(row.status).toBe("open");
    expect(Number(row.po_no)).toBe(no);
  });

  it("open -> partial -> closed is fine; closed -> open is not", async () => {
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    await bk.client.query("SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, po, db.userA]);
    await bk.client.query(
      "UPDATE purchase_orders SET status='partial' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po]);
    await bk.client.query(
      "UPDATE purchase_orders SET status='closed' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po]);
    await expect(bk.client.query(
      "UPDATE purchase_orders SET status='open' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rejects.toThrow(/cannot go from closed back to open/);
    await expect(bk.client.query(
      "UPDATE purchase_orders SET status='partial' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rejects.toThrow(/cannot go from closed back to partial/);
  });

  it("an ISSUED purchase order cannot be DELETED — its number would be reused", async () => {
    // purchase_orders_forward_only was BEFORE UPDATE only, and app holds
    // DELETE: deleting an issued PO frees the counter to hand the SAME number
    // to a second one while the first sits on a vendor's desk. Same fix, same
    // shape, as invoices_immutable_when_issued (0005).
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    const no = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po, db.userA])).rows[0].n);

    const app = await appFor(bk);
    await expect(app.query(
      "DELETE FROM purchase_orders WHERE company_id=$1 AND id=$2", [bk.companyId, po]))
      .rejects.toThrow(new RegExp(`purchase order ${no} has been issued — cancel it instead of deleting it`));
    // the superuser is bound by the same row trigger
    await expect(db.admin.query("DELETE FROM purchase_orders WHERE id=$1", [po]))
      .rejects.toThrow(/has been issued — cancel it instead of deleting it/);

    expect((await bk.client.query(
      "SELECT count(*)::int AS n FROM purchase_orders WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rows[0].n).toBe(1);

    // ...and no second PO can ever claim that number
    const po2 = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    const no2 = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po2, db.userA])).rows[0].n);
    expect(no2).toBe(no + 1);
  });

  it("an UNISSUED draft PO can be deleted WITH its lines, not met with raw FK text", async () => {
    // A PO line has no existence apart from its purchase order, so the delete
    // cascades. Without the cascade the user sees Postgres's own foreign-key
    // message, which §9 does not permit.
    const po = await draftPo(bk, vendor, [
      { itemId: item, qty: "1", unitCost: "1.000000" },
      { description: "Freight", qty: "1", unitCost: "9.000000" },
    ]);
    const app = await appFor(bk);
    await expect(app.query(
      "DELETE FROM purchase_orders WHERE company_id=$1 AND id=$2", [bk.companyId, po]))
      .resolves.toBeTruthy();
    expect((await bk.client.query(
      "SELECT count(*)::int AS n FROM purchase_order_lines WHERE company_id=$1 AND po_id=$2",
      [bk.companyId, po])).rows[0].n).toBe(0);
  });

  it("a draft PO can still be cancelled or edited freely, and keeps no number", async () => {
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    await bk.client.query(
      "UPDATE purchase_orders SET memo='changed my mind', expected_date='2024-04-01' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po]);
    // A draft abandoned before it was ever sent is cancelled WITHOUT a number:
    // burning one on a PO no vendor ever saw would leave a permanent gap in
    // the sequence that nobody could explain.
    await bk.client.query(
      "UPDATE purchase_orders SET status='cancelled' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po]);
    const row = (await bk.client.query(
      "SELECT status, po_no FROM purchase_orders WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rows[0];
    expect(row.status).toBe("cancelled");
    expect(row.po_no).toBeNull();
    // and it cannot be resurrected
    await expect(bk.client.query(
      "UPDATE purchase_orders SET status='draft' WHERE company_id=$1 AND id=$2",
      [bk.companyId, po])).rejects.toThrow(/cannot go from cancelled back to draft/);
  });
});

// ===========================================================================
// builds are history the moment they exist
// ===========================================================================

describe("builds immutability", () => {
  let bk: Required<Books>;
  let buildId = 0;
  let buildNo = 0;
  beforeAll(async () => {
    bk = await newBooks("Build Armor Co");
    const asm = await stockItem(bk, "Finished Good");
    const fixtureEntry = await postedFixtureEntry(bk);
    const r = await db.admin.query(
      `INSERT INTO builds (company_id, build_no, assembly_item_id, qty, built_on,
                           entry_id, created_by)
       VALUES ($1, allocate_build_no($1), $2, '3', '2024-03-01', $4, $3)
       RETURNING id, build_no`, [bk.companyId, asm, db.userA, fixtureEntry]);
    buildId = Number(r.rows[0].id);
    buildNo = Number(r.rows[0].build_no);
  }, 30_000);

  it("the app role cannot renumber, requantify or delete a build", async () => {
    for (const sql of [
      "UPDATE builds SET build_no = 42 WHERE company_id=$1 AND id=$2",
      "UPDATE builds SET qty = '9999' WHERE company_id=$1 AND id=$2",
      "UPDATE builds SET built_on = '2020-01-01' WHERE company_id=$1 AND id=$2",
      "UPDATE builds SET memo = 'rewritten history' WHERE company_id=$1 AND id=$2",
      "DELETE FROM builds WHERE company_id=$1 AND id=$2",
    ]) {
      await expect(bk.client.query(sql, [bk.companyId, buildId]))
        .rejects.toThrow(new RegExp(`build #${buildNo} is a permanent record`));
    }
    // the plain app role hits the same trigger (it holds UPDATE and DELETE
    // deliberately, so the refusal is English and not a bare privilege error)
    const app = await appFor(bk);
    await expect(app.query(
      "UPDATE builds SET build_no = 42 WHERE company_id=$1 AND id=$2", [bk.companyId, buildId]))
      .rejects.toThrow(new RegExp(`build #${buildNo} is a permanent record`));
    await expect(app.query(
      "DELETE FROM builds WHERE company_id=$1 AND id=$2", [bk.companyId, buildId]))
      .rejects.toThrow(/is a permanent record/);
    // and the superuser is bound by the same row trigger
    await expect(db.admin.query("DELETE FROM builds WHERE id=$1", [buildId]))
      .rejects.toThrow(/is a permanent record/);

    const row = (await bk.client.query(
      "SELECT build_no, qty, built_on, status FROM builds WHERE company_id=$1 AND id=$2",
      [bk.companyId, buildId])).rows[0];
    expect(Number(row.build_no)).toBe(buildNo);
    expect(row.qty).toBe("3.0000");
    expect(row.status).toBe("posted");
  });

  it("the one permitted change is posted -> reversed, on its own", async () => {
    // a status flip smuggling another change with it is still refused
    await expect(bk.client.query(
      "UPDATE builds SET status='reversed', qty='1' WHERE company_id=$1 AND id=$2",
      [bk.companyId, buildId])).rejects.toThrow(/is a permanent record/);

    await bk.client.query(
      "UPDATE builds SET status='reversed' WHERE company_id=$1 AND id=$2",
      [bk.companyId, buildId]);
    expect((await bk.client.query(
      "SELECT status FROM builds WHERE company_id=$1 AND id=$2",
      [bk.companyId, buildId])).rows[0].status).toBe("reversed");

    // and there is no way back
    await expect(bk.client.query(
      "UPDATE builds SET status='posted' WHERE company_id=$1 AND id=$2",
      [bk.companyId, buildId])).rejects.toThrow(/is a permanent record/);
  });

  it("TRUNCATE is refused on builds and every other inventory table", async () => {
    for (const t of ["builds", "purchase_orders", "purchase_order_lines",
                     "assemblies", "po_counters", "build_counters"]) {
      await expect(db.admin.query(`TRUNCATE ${t} CASCADE`))
        .rejects.toThrow(/TRUNCATE is not allowed/);
    }
  });
});

// ===========================================================================
// Per-company numbering (0010's lock-first ordering, mirrored)
// ===========================================================================

describe("per-company PO and build numbering", () => {
  it("po_no starts at 1 in every company independently", async () => {
    const one = await newBooks("Counter One Co");
    const two = await newBooks("Counter Two Co");
    for (const bk of [one, two]) {
      const vendor = await createVendor(bk.client, bk.companyId, "Supplier");
      const item = await stockItem(bk, "Ordered Thing");
      for (const expected of [1, 2, 3]) {
        const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
        const no = Number((await bk.client.query(
          "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po, db.userA])).rows[0].n);
        expect(no).toBe(expected);
      }
    }
  });

  it("build_no starts at 1 in every company independently", async () => {
    // allocate_build_no is internal (EXECUTE revoked from PUBLIC), so it is
    // exercised the way Task 3's post_build will call it — from inside the
    // definer boundary. The superuser stands in for that here.
    const one = await newBooks("Build Counter One Co");
    const two = await newBooks("Build Counter Two Co");
    for (const bk of [one, two]) {
      for (const expected of [1, 2, 3]) {
        const n = Number((await db.admin.query(
          "SELECT allocate_build_no($1) AS n", [bk.companyId])).rows[0].n);
        expect(n).toBe(expected);
      }
    }
    await expect((await appFor(one)).query("SELECT allocate_build_no($1)", [one.companyId]))
      .rejects.toThrow(/permission denied/i);
  });

  it("set_next_po_no refuses a value that does not exceed the highest used, and otherwise fast-forwards", async () => {
    const bk = await newBooks("PO Setter Co");
    const vendor = await createVendor(bk.client, bk.companyId, "Supplier");
    const item = await stockItem(bk, "Ordered Thing");
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    await bk.client.query("SELECT issue_purchase_order($1,$2,$3)", [bk.companyId, po, db.userA]);

    await expect(bk.client.query("SELECT set_next_po_no($1,1,$2)", [bk.companyId, db.userA]))
      .rejects.toThrow(/next purchase order number must be greater than the highest used \(1\)/);

    await bk.client.query("SELECT set_next_po_no($1,500,$2)", [bk.companyId, db.userA]);
    const po2 = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    const no2 = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po2, db.userA])).rows[0].n);
    expect(no2).toBe(500);
  });

  it("set_next_build_no refuses a value that does not exceed the highest used, and otherwise fast-forwards", async () => {
    const bk = await newBooks("Build Setter Co");
    const asm = await stockItem(bk, "Finished Good");
    await db.admin.query(
      `INSERT INTO builds (company_id, build_no, assembly_item_id, qty, built_on,
                           entry_id, created_by)
       VALUES ($1, allocate_build_no($1), $2, '1', '2024-03-01', $4, $3)`,
      [bk.companyId, asm, db.userA, await postedFixtureEntry(bk)]);

    await expect(bk.client.query("SELECT set_next_build_no($1,1,$2)", [bk.companyId, db.userA]))
      .rejects.toThrow(/next build number must be greater than the highest used \(1\)/);

    await bk.client.query("SELECT set_next_build_no($1,90,$2)", [bk.companyId, db.userA]);
    const n = Number((await db.admin.query(
      "SELECT allocate_build_no($1) AS n", [bk.companyId])).rows[0].n);
    expect(n).toBe(90);
  });

  it("set_next_po_no serializes against a concurrent issue_purchase_order — no stale fast-forward", async () => {
    // 0010's TOCTOU fix, mirrored: the counter row is locked FIRST, so a
    // concurrent allocation cannot commit a fresh number into the gap between
    // the max(po_no) read and the counter update.
    const bk = await newBooks("PO Race Co");
    const vendor = await createVendor(bk.client, bk.companyId, "Supplier");
    const item = await stockItem(bk, "Ordered Thing");
    const po = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);

    const a = await db.appClient(bk.companyId);   // issues, held open
    const b = await db.appClient(bk.companyId);   // races the setter

    await a.query("BEGIN");
    const issued = Number((await a.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po, db.userA])).rows[0].n);

    const pending = inFlight(b.query(
      "SELECT set_next_po_no($1,$2,$3)", [bk.companyId, issued, db.userA]));
    await new Promise((r) => setTimeout(r, 300));
    // Still blocked on the counter row A locked first — 0010's fix.
    pending.stillBlocked("B's set_next_po_no");
    await a.query("COMMIT");
    await expect(pending.settled()).rejects.toThrow(
      /next purchase order number must be greater than the highest used/);

    const counter = await bk.client.query(
      "SELECT next_po_no FROM po_counters WHERE company_id = $1", [bk.companyId]);
    expect(Number(counter.rows[0].next_po_no)).toBe(issued + 1);

    const po2 = await draftPo(bk, vendor, [{ itemId: item, qty: "1", unitCost: "1.000000" }]);
    const no2 = Number((await bk.client.query(
      "SELECT issue_purchase_order($1,$2,$3) AS n", [bk.companyId, po2, db.userA])).rows[0].n);
    expect(no2).toBe(issued + 1);
  });

  it("counter setters and reports refuse another company's session", async () => {
    await expect(cB.query("SELECT set_next_po_no($1,5,$2)", [db.companyA, db.userA]))
      .rejects.toThrow(/session company does not match/);
    await expect(cB.query("SELECT set_next_build_no($1,5,$2)", [db.companyA, db.userA]))
      .rejects.toThrow(/session company does not match/);
    await expect(cB.query("SELECT * FROM check_inventory_ties($1, NULL)", [db.companyA]))
      .rejects.toThrow(/session company does not match/);
    await expect(cB.query("SELECT * FROM inventory_valuation($1, NULL)", [db.companyA]))
      .rejects.toThrow(/session company does not match/);
    await expect(cB.query("SELECT item_qty_on_hand($1,$2)", [db.companyA, fixtureItemA]))
      .rejects.toThrow(/session company does not match/);
    await expect(cB.query("SELECT item_average_cost($1,$2)", [db.companyA, fixtureItemA]))
      .rejects.toThrow(/session company does not match/);
  });
});

// ===========================================================================
// Tenant isolation (§4) — on every new table, non-vacuously
// ===========================================================================

describe("row-level security on every inventory table", () => {
  const tables = ["items", "inventory_movements", "po_counters", "purchase_orders",
                  "purchase_order_lines", "assemblies", "build_counters", "builds"];

  beforeAll(async () => {
    // Give company B something on every table so the cross-company assertions
    // below cannot pass vacuously.
    const vendorB = await createVendor(cB, db.companyB, "B Supplier");
    const poB = Number((await cB.query(
      `INSERT INTO purchase_orders (company_id, vendor_id, order_date, created_by)
       VALUES ($1,$2,'2024-02-01',$3) RETURNING id`,
      [db.companyB, vendorB, db.userB])).rows[0].id);
    await cB.query(
      `INSERT INTO purchase_order_lines (company_id, po_id, item_id, qty_ordered, unit_cost)
       VALUES ($1,$2,$3,'5','2.000000')`, [db.companyB, poB, fixtureItemB]);
    await cB.query("SELECT issue_purchase_order($1,$2,$3)", [db.companyB, poB, db.userB]);
    await cB.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'2')`, [db.companyB, fixtureItemB, subItemB]);
    const booksBFixture: Books = {
      companyId: db.companyB, client: sysB, inventory: invB, cogs: cogsB,
      ap: db.accounts["2000"].B, income: db.accounts["4000"].B,
    };
    await db.admin.query(
      `INSERT INTO builds (company_id, build_no, assembly_item_id, qty, built_on,
                           entry_id, created_by)
       VALUES ($1, allocate_build_no($1), $2, '1', '2024-03-01', $4, $3)`,
      [db.companyB, fixtureItemB, db.userB, await postedFixtureEntry(booksBFixture)]);
  }, 30_000);

  it("every table really does have rows on the other side (the assertions below are not vacuous)", async () => {
    for (const t of tables) {
      const n = (await db.admin.query(
        `SELECT count(*)::int AS n FROM ${t} WHERE company_id = $1`, [db.companyB])).rows[0].n;
      expect({ table: t, hasRows: n > 0 }).toEqual({ table: t, hasRows: true });
    }
  });

  it("company A sees none of company B's inventory rows", async () => {
    for (const t of tables) {
      const n = (await c.query(
        `SELECT count(*)::int AS n FROM ${t} WHERE company_id = $1`, [db.companyB])).rows[0].n;
      expect({ table: t, n }).toEqual({ table: t, n: 0 });
    }
  });

  it("a session with no company at all sees nothing anywhere", async () => {
    const anon = await db.appClient(null);
    for (const t of tables) {
      const n = (await anon.query(`SELECT count(*)::int AS n FROM ${t}`)).rows[0].n;
      expect({ table: t, n }).toEqual({ table: t, n: 0 });
    }
  });

  it("company A cannot insert a purchase order or PO line into company B (RLS WITH CHECK)", async () => {
    const vendorB = Number((await cB.query(
      "SELECT id FROM vendors WHERE company_id = $1 LIMIT 1", [db.companyB])).rows[0].id);
    await expect(c.query(
      `INSERT INTO purchase_orders (company_id, vendor_id, order_date, created_by)
       VALUES ($1,$2,'2024-02-01',$3)`, [db.companyB, vendorB, db.userA]))
      .rejects.toThrow(/row-level security/);
    const poB = Number((await cB.query(
      "SELECT id FROM purchase_orders WHERE company_id = $1 LIMIT 1", [db.companyB])).rows[0].id);
    await expect(c.query(
      `INSERT INTO purchase_order_lines (company_id, po_id, item_id, qty_ordered, unit_cost)
       VALUES ($1,$2,$3,'1','1')`, [db.companyB, poB, fixtureItemB]))
      .rejects.toThrow(/row-level security/);
  });

  it("company A cannot create an item or a BOM in company B — the guards see nothing to work with", async () => {
    // Both of these are refused BEFORE row-level security even gets a say:
    // the BEFORE-INSERT guards read accounts / items through the caller's own
    // RLS scope, and company B's rows simply do not exist there.
    await expect(c.query(
      `INSERT INTO items (company_id, name, kind, inventory_account_id, cogs_account_id)
       VALUES ($1,'Sneaky Item','inventory',$2,$3)`, [db.companyB, invB, cogsB]))
      .rejects.toThrow(/row-level security|account \d+ not found/);
    await expect(c.query(
      `INSERT INTO assemblies (company_id, assembly_item_id, component_item_id, qty_per)
       VALUES ($1,$2,$3,'1')`, [db.companyB, fixtureItemB, subItemB]))
      .rejects.toThrow(/row-level security|only a stocked item can be built from parts/);
  });

  it("a movement can never reference another company's item or entry", async () => {
    await expect(sysA.query(
      `SELECT record_movement($1,$2::bigint,'2024-03-01'::date,1::numeric,
                              1::numeric,'receipt',NULL,NULL,NULL)`,
      [db.companyA, fixtureItemB])).rejects.toThrow(/item \d+ not found/);
  });

  it("each company's PO counter is its own", async () => {
    const rows = (await db.admin.query(
      "SELECT company_id, next_po_no FROM po_counters WHERE company_id = ANY($1::uuid[])",
      [[db.companyA, db.companyB]])).rows;
    expect(rows.length).toBe(1); // only B has ever issued a PO in this pair
    expect(rows[0].company_id).toBe(db.companyB);
  });
});
