// Integration tests for the AP vendors + bills API (Task 2 of
// docs/superpowers/plans/2026-08-01-ap.md): vendor CRUD + unique-name
// conflict, draft bill create/edit with server-computed amounts (incl. the
// round-half-up-to-cents rule), issue (incl. document-only) with a ledger
// cross-check, void, draft-only PATCH enforcement, list-state derivation,
// detail payload, RLS isolation, and English validation errors. Mirrors
// tests/server/ar-invoices.test.ts structurally (same createTestDb, app.inject,
// seeded chart of accounts) with vendor/bill vocabulary.

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

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

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

let db: TestDb;
let app: FastifyInstance;

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

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

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

async function createVendor(name: string, extra: Record<string, unknown> = {}): Promise<string> {
  const res = await post(app, db.companyA, "/api/c/vendors", { name, ...extra });
  expect(res.statusCode).toBe(201);
  return res.json().id;
}

interface LineInput {
  description?: string;
  quantity?: string;
  unitPrice: string;
  categoryAccountId?: string;
}
function draftBody(vendorId: string, lines: LineInput[], extra: Record<string, unknown> = {}) {
  return {
    vendorId,
    billDate: "2024-06-01",
    lines: lines.map((l) => ({ categoryAccountId: String(db.accounts["6000"]!.A), ...l })),
    ...extra,
  };
}

// ---------------------------------------------------------------------------
// vendors
// ---------------------------------------------------------------------------

describe("vendors", () => {
  it("creates a vendor with defaults (termsDays 30, track1099 false, active true)", async () => {
    const res = await post(app, db.companyA, "/api/c/vendors", { name: "Acme Supply Co" });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body).toMatchObject({
      name: "Acme Supply Co", email: null, phone: null, address: null,
      termsDays: 30, track1099: false, active: true,
    });
    expect(typeof body.id).toBe("string");
  });

  it("creates a vendor with all fields, a custom terms, and track1099 true", async () => {
    const res = await post(app, db.companyA, "/api/c/vendors", {
      name: "Full Fields Vendor Co", email: "ap@fullfields.example", phone: "555-1234",
      address: "1 Main St", termsDays: 45, track1099: true,
    });
    expect(res.statusCode).toBe(201);
    expect(res.json()).toMatchObject({
      name: "Full Fields Vendor Co", email: "ap@fullfields.example", phone: "555-1234",
      address: "1 Main St", termsDays: 45, track1099: true, active: true,
    });
  });

  it("400s in English for a missing name", async () => {
    const res = await post(app, db.companyA, "/api/c/vendors", {});
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/name/i);
  });

  it("400s in English for a malformed email", async () => {
    const res = await post(app, db.companyA, "/api/c/vendors", {
      name: "Bad Email Vendor Co", email: "not-an-email",
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/valid email/i);
  });

  it("409s in English on a duplicate name within the same company", async () => {
    await createVendor("Duplicate Vendor Co");
    const res = await post(app, db.companyA, "/api/c/vendors", { name: "Duplicate Vendor Co" });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/already exists/i);
  });

  it("allows the same name in a different company", async () => {
    const res = await post(app, db.companyB, "/api/c/vendors", { name: "Duplicate Vendor Co" });
    expect(res.statusCode).toBe(201);
  });

  it("PATCH replaces contact fields, terms, track1099, and active", async () => {
    const id = await createVendor("Editable Vendor Co", { termsDays: 15 });
    const res = await patch(app, db.companyA, `/api/c/vendors/${id}`, {
      name: "Editable Vendor Co Renamed", email: "new@editable.example", phone: "555-9999",
      address: "2 New St", termsDays: 60, track1099: true, active: false,
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toMatchObject({
      id, name: "Editable Vendor Co Renamed", email: "new@editable.example", phone: "555-9999",
      address: "2 New St", termsDays: 60, track1099: true, active: false,
    });
  });

  it("404s for a made-up vendor id", async () => {
    const res = await patch(app, db.companyA, "/api/c/vendors/999999999", { name: "Ghost" });
    expect(res.statusCode).toBe(404);
  });

  it("404s (not 500) when the vendor belongs to another company (RLS)", async () => {
    const id = await createVendor("Company A Only Vendor Co");
    const res = await patch(app, db.companyB, `/api/c/vendors/${id}`, { name: "Hijacked" });
    expect(res.statusCode).toBe(404);
  });

  it("GET list includes a zero balance and null oldest-due-date for a vendor with no bills", async () => {
    const res = await get(app, db.companyA, "/api/c/vendors");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    const editable = body.find((v: { name: string }) => v.name === "Editable Vendor Co Renamed");
    expect(editable).toMatchObject({ balance: "0.0000", oldestOpenDueDate: null });
  });

  it("shows nothing for company B beyond its own vendors (RLS)", async () => {
    const res = await get(app, db.companyB, "/api/c/vendors");
    const names = res.json().map((v: { name: string }) => v.name);
    expect(names).not.toContain("Editable Vendor Co Renamed");
  });

  it("derived balance sums open bills (including document_only) and finds the oldest open due date", async () => {
    const id = await createVendor("Balance Derivation Vendor Co");
    const create1 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "100.00" },
    ], { billDate: "2024-01-01", dueDate: "2024-01-31" }));
    const create2 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "50.00" },
    ], { billDate: "2024-02-01", dueDate: "2024-02-15", documentOnly: true }));
    await post(app, db.companyA, `/api/c/bills/${create1.json().billId}/issue`);
    await post(app, db.companyA, `/api/c/bills/${create2.json().billId}/issue`);

    const res = await get(app, db.companyA, "/api/c/vendors");
    const row = res.json().find((v: { id: string }) => v.id === id);
    expect(row.balance).toBe("150.0000");
    expect(row.oldestOpenDueDate).toBe("2024-01-31");
  });
});

// ---------------------------------------------------------------------------
// draft bills — creation, computed amounts, editing
// ---------------------------------------------------------------------------

describe("POST /api/c/bills — draft creation", () => {
  it("creates a draft with default quantity 1 and dueDate = billDate + termsDays", async () => {
    const id = await createVendor("Default Terms Vendor Co", { termsDays: 20 });
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "250.00", description: "Supplies" },
    ]));
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.status).toBe("draft");
    expect(body.documentOnly).toBe(false);
    expect(body.refNo).toBeNull();
    expect(body.dueDate).toBe("2024-06-21"); // 2024-06-01 + 20 days
    expect(body.total).toBe("250.0000");
    expect(body.lines).toHaveLength(1);
    expect(body.lines[0]).toMatchObject({
      description: "Supplies", quantity: "1", unitPrice: "250.00", amount: "250.0000",
    });
  });

  it("respects an explicit dueDate over the vendor's terms", async () => {
    const id = await createVendor("Explicit Due Vendor Co", { termsDays: 30 });
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ], { dueDate: "2099-01-01" }));
    expect(res.statusCode).toBe(201);
    expect(res.json().dueDate).toBe("2099-01-01");
  });

  it("carries the vendor's own refNo", async () => {
    const id = await createVendor("Ref No Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ], { refNo: "INV-5001" }));
    expect(res.statusCode).toBe(201);
    expect(res.json().refNo).toBe("INV-5001");
  });

  // Regression coverage (FIX 2): blankToUndefined() must trim BEFORE
  // checking for blankness, so a whitespace-only refNo collapses to
  // undefined/NULL rather than being stored as a literal "" — which would
  // otherwise collide with any other blank refNo for the same vendor under
  // the bills_vendor_ref_no_unique partial index (WHERE ref_no IS NOT NULL
  // — "" satisfies that, NULL doesn't).
  it("a whitespace-only refNo stores NULL, so two such bills coexist freely", async () => {
    const id = await createVendor("Whitespace Ref No Vendor Co");
    const res1 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ], { refNo: "   " }));
    expect(res1.statusCode).toBe(201);
    expect(res1.json().refNo).toBeNull();

    const res2 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "20.00" },
    ], { refNo: "\t\n  " }));
    expect(res2.statusCode).toBe(201);
    expect(res2.json().refNo).toBeNull();

    const detail = await get(app, db.companyA, `/api/c/bills/${res1.json().billId}`);
    expect(detail.json().refNo).toBeNull();
  });

  it("computes amount = quantity * unitPrice exactly (3 x 33.33 = 99.99)", async () => {
    const id = await createVendor("Exact Math Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "3", unitPrice: "33.33" },
    ]));
    expect(res.statusCode).toBe(201);
    expect(res.json().lines[0].amount).toBe("99.9900");
    expect(res.json().total).toBe("99.9900");
  });

  it("rounds HALF-UP to the nearest cent (0.5 x 99.99 = 49.995 -> 50.00)", async () => {
    // Rounding rule (binding, see server/src/bills.ts's file header): amount
    // is computed server-side via integer arithmetic and rounded HALF-UP to
    // the nearest cent, never banker's rounding and never a JS float.
    // 0.5 * 99.99 = 49.995 exactly, which has no exact cent representation,
    // so it resolves UP to 50.00.
    const id = await createVendor("Half Up Rounding Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "0.5", unitPrice: "99.99" },
    ]));
    expect(res.statusCode).toBe(201);
    expect(res.json().lines[0].amount).toBe("50.0000");
  });

  // Overflow guard (server/src/bills.ts's MAX_LINE_AMOUNT_CENTS): each of
  // quantity/unitPrice is individually bounded by its own schema (8 and 13
  // integer digits respectively), but their PRODUCT can still exceed
  // numeric(19,4)'s 15-digit integer-part limit — computeLineAmount() catches
  // that and resolveLines() turns it into a 422, never a raw Postgres
  // "numeric field overflow" 500.
  it("422s in English when a large quantity drives the computed amount over the storage limit", async () => {
    const id = await createVendor("Overflow Qty Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "99999999", unitPrice: "99999999.99" }, // 8-digit qty (its schema max)
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("That line amount is too large.");
  });

  it("422s in English when a large unit price drives the computed amount over the storage limit", async () => {
    const id = await createVendor("Overflow Price Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "1000", unitPrice: "9999999999999.99" }, // 13-digit price (its schema max)
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("That line amount is too large.");
  });

  it("422s in English when neither factor alone is extreme but their product overflows", async () => {
    const id = await createVendor("Overflow Product Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "12345678", unitPrice: "123456789012.34" },
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("That line amount is too large.");
  });

  it("400s in English when quantity exceeds its 8-digit schema bound", async () => {
    const id = await createVendor("Qty Digit Bound Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { quantity: "123456789", unitPrice: "1.00" }, // 9 digits
    ]));
    expect(res.statusCode).toBe(400);
  });

  it("400s in English when unitPrice exceeds its 13-digit schema bound", async () => {
    const id = await createVendor("Price Digit Bound Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "12345678901234.00" }, // 14 digits
    ]));
    expect(res.statusCode).toBe(400);
  });

  it("sums multiple lines into the bill total", async () => {
    const id = await createVendor("Multi Line Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "100.00" }, { unitPrice: "25.50" },
    ]));
    expect(res.statusCode).toBe(201);
    expect(res.json().total).toBe("125.5000");
  });

  it("creates a document-only draft (no accounting impact yet)", async () => {
    const id = await createVendor("Doc Only Draft Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "2789.80" },
    ], { documentOnly: true }));
    expect(res.statusCode).toBe(201);
    expect(res.json().documentOnly).toBe(true);
    expect(res.json().status).toBe("draft");
  });

  it("422s in English for an unknown vendorId", async () => {
    const res = await post(app, db.companyA, "/api/c/bills", draftBody("999999999", [
      { unitPrice: "10.00" },
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/vendor/i);
  });

  it("422s in English when a line's categoryAccountId is the company's own A/P account", async () => {
    const id = await createVendor("Bad Line Account Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00", categoryAccountId: String(db.accounts["2000"]!.A) }, // Accounts Payable
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/category account/i);
  });

  it("422s in English when a line's categoryAccountId doesn't exist", async () => {
    const id = await createVendor("Missing Line Account Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00", categoryAccountId: "999999999" },
    ]));
    expect(res.statusCode).toBe(422);
  });

  it("400s in English with no lines", async () => {
    const id = await createVendor("No Lines Vendor Co");
    const res = await post(app, db.companyA, "/api/c/bills", {
      vendorId: id, billDate: "2024-06-01", lines: [],
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/at least one line/i);
  });

  it("409s in English on a duplicate (vendor, refNo)", async () => {
    const id = await createVendor("Dup Ref No Vendor Co");
    const first = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ], { refNo: "REF-900" }));
    expect(first.statusCode).toBe(201);

    const dup = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "20.00" },
    ], { refNo: "REF-900" }));
    expect(dup.statusCode).toBe(409);
    expect(dup.json().error).toBe(
      "You've already entered a bill with reference REF-900 for this vendor.",
    );
  });

  it("a different vendor may reuse the same refNo", async () => {
    const id1 = await createVendor("Shared Ref Vendor One Co");
    const id2 = await createVendor("Shared Ref Vendor Two Co");
    await post(app, db.companyA, "/api/c/bills", draftBody(id1, [
      { unitPrice: "10.00" },
    ], { refNo: "SHARED-1" }));
    const res = await post(app, db.companyA, "/api/c/bills", draftBody(id2, [
      { unitPrice: "10.00" },
    ], { refNo: "SHARED-1" }));
    expect(res.statusCode).toBe(201);
  });

  it("multiple bills with no refNo coexist freely", async () => {
    const id = await createVendor("No Ref Coexist Vendor Co");
    const res1 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [{ unitPrice: "5.00" }]));
    const res2 = await post(app, db.companyA, "/api/c/bills", draftBody(id, [{ unitPrice: "6.00" }]));
    expect(res1.statusCode).toBe(201);
    expect(res2.statusCode).toBe(201);
  });

  it.each(["12.345", "-5", "abc", "1e3", "0", "0.00", "007", "01.50"])(
    "400s for an invalid unit price %s", async (unitPrice) => {
      const id = await createVendor("Bad Price Vendor Co " + unitPrice);
      const res = await post(app, db.companyA, "/api/c/bills", draftBody(id, [{ unitPrice }]));
      expect(res.statusCode).toBe(400);
    },
  );
});

describe("PATCH /api/c/bills/:id — draft only", () => {
  it("replaces header fields and the full line set while still a draft", async () => {
    const id = await createVendor("Patchable Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const billId = create.json().billId;

    const res = await patch(app, db.companyA, `/api/c/bills/${billId}`, draftBody(id, [
      { unitPrice: "20.00" }, { unitPrice: "5.00" },
    ], { memo: "revised", refNo: "REVISED-1" }));
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.memo).toBe("revised");
    expect(body.refNo).toBe("REVISED-1");
    expect(body.total).toBe("25.0000");
    expect(body.lines).toHaveLength(2);
  });

  it("404s for a made-up bill id", async () => {
    const id = await createVendor("Patch 404 Vendor Co");
    const res = await patch(app, db.companyA, "/api/c/bills/999999999", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    expect(res.statusCode).toBe(404);
  });

  it("409s in English when the bill has already been issued", async () => {
    const id = await createVendor("Frozen After Issue Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const billId = create.json().billId;
    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);

    const res = await patch(app, db.companyA, `/api/c/bills/${billId}`, draftBody(id, [
      { unitPrice: "999.00" },
    ]));
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/issued/i);
    expect(res.json().error).not.toMatch(/debit|credit/i);
  });

  // Regression coverage (FIX 2): the PATCH path shares bills.ts's
  // mapDuplicateRefNo() with POST, but was previously untested — assert the
  // exact quoted-reference 409, not just a loose match, so a future refactor
  // that breaks the PATCH-specific wiring (e.g. forgetting to pass the
  // catch-block's body.refNo through) is caught immediately.
  it("409s in English with the specific reference when PATCH collides with an existing (vendor, refNo)", async () => {
    const id = await createVendor("Patch Dup Ref No Vendor Co");
    const first = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ], { refNo: "PATCH-DUP-1" }));
    expect(first.statusCode).toBe(201);

    const second = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "20.00" },
    ], { refNo: "SOMETHING-ELSE" }));
    expect(second.statusCode).toBe(201);
    const secondBillId = second.json().billId;

    const res = await patch(app, db.companyA, `/api/c/bills/${secondBillId}`, draftBody(id, [
      { unitPrice: "20.00" },
    ], { refNo: "PATCH-DUP-1" }));
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe(
      "You've already entered a bill with reference PATCH-DUP-1 for this vendor.",
    );
    expect(res.json().error).not.toMatch(/debit|credit/i);
  });
});

// ---------------------------------------------------------------------------
// issue — ledger cross-check, document-only, already-issued
// ---------------------------------------------------------------------------

describe("POST /api/c/bills/:id/issue", () => {
  it("posts a balanced entry (A/P credited, category debited) and the summary reads naturally", async () => {
    // termsDays 29 + billDate 2024-08-01 = dueDate 2024-08-30, chosen so this
    // exercises this implementation's issue-summary phrasing (bills.ts's
    // POST /:id/issue) verbatim: "Bill from Cascade Supply Co for $150.00 recorded —
    // due Aug 30." This exact wording is this implementation's own choice,
    // not a requirement written down in the plan/design docs — nothing there
    // prescribes it, so a future rewording of the summary sentence is not,
    // by itself, a spec violation; update this assertion to match if that
    // happens.
    const id = await createVendor("Cascade Supply Co", { termsDays: 29 });
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "150.00" },
    ], { billDate: "2024-08-01", refNo: "REF-001" }));
    const billId = create.json().billId;
    expect(create.json().dueDate).toBe("2024-08-30");

    const res = await post(app, db.companyA, `/api/c/bills/${billId}/issue`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.summary).toBe("Bill from Cascade Supply Co for $150.00 recorded — due Aug 30.");

    // Ledger cross-check: A/P account drill-through shows the bill total
    // (credited), and the category account was debited for the same amount.
    const apEntries = await get(app, db.companyA, `/api/c/accounts/${db.accounts["2000"]!.A}/entries`);
    const apRow = apEntries.json().entries.find((e: { memo: string | null }) => e.memo === "Bill REF-001");
    expect(apRow).toBeTruthy();
    expect(apRow.amountIntoAccount).toBe("150.0000");

    const expenseEntries = await get(app, db.companyA, `/api/c/accounts/${db.accounts["6000"]!.A}/entries`);
    const expenseRow = expenseEntries.json().entries.find(
      (e: { memo: string | null }) => e.memo === "Bill REF-001");
    expect(expenseRow).toBeTruthy();
    expect(expenseRow.amountIntoAccount).toBe("150.0000");
  });

  it("document-only issue posts NO ledger entry", async () => {
    const id = await createVendor("Doc Only Issue Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "2789.80" },
    ], { documentOnly: true }));
    const billId = create.json().billId;

    const res = await post(app, db.companyA, `/api/c/bills/${billId}/issue`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.summary).toBe(
      "Bill from Doc Only Issue Vendor Co for $2,789.80 recorded for tracking only — no accounting impact.",
    );

    const detail = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(detail.json().postedEntryId).toBeNull();
    expect(detail.json().status).toBe("open");
  });

  it("409s in English when issuing an already-issued bill", async () => {
    const id = await createVendor("Double Issue API Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const billId = create.json().billId;
    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);

    const res = await post(app, db.companyA, `/api/c/bills/${billId}/issue`);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/already been issued/i);
  });

  it("404s for a made-up bill id", async () => {
    const res = await post(app, db.companyA, "/api/c/bills/999999999/issue");
    expect(res.statusCode).toBe(404);
  });

  it("404s (not 500) when the bill belongs to another company (RLS)", async () => {
    const id = await createVendor("Issue RLS Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const res = await post(app, db.companyB, `/api/c/bills/${create.json().billId}/issue`);
    expect(res.statusCode).toBe(404);
  });
});

// ---------------------------------------------------------------------------
// void
// ---------------------------------------------------------------------------

describe("POST /api/c/bills/:id/void", () => {
  it("restores the A/P account balance and marks the bill void", async () => {
    const id = await createVendor("Void Restore Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "300.00" },
    ]));
    const billId = create.json().billId;

    const tbBefore = await get(app, db.companyA, "/api/c/reports/trial-balance");
    const apBefore = tbBefore.json().rows.find(
      (r: { code: string }) => r.code === "2000")?.balance ?? "0.0000";

    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);
    const voidRes = await post(app, db.companyA, `/api/c/bills/${billId}/void`, { date: "2024-06-15" });
    expect(voidRes.statusCode).toBe(200);
    expect(voidRes.json().summary).toMatch(/voided this bill/i);

    const tbAfter = await get(app, db.companyA, "/api/c/reports/trial-balance");
    const apAfter = tbAfter.json().rows.find(
      (r: { code: string }) => r.code === "2000")?.balance ?? "0.0000";
    expect(apAfter).toBe(apBefore);

    const detail = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(detail.json().status).toBe("void");
    expect(detail.json().voidEntryId).not.toBeNull();
  });

  it("409s in English when voiding a draft (not yet issued)", async () => {
    const id = await createVendor("Void Draft Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const res = await post(app, db.companyA, `/api/c/bills/${create.json().billId}/void`);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/issue this one first/i);
  });

  it("404s for a made-up bill id", async () => {
    const res = await post(app, db.companyA, "/api/c/bills/999999999/void");
    expect(res.statusCode).toBe(404);
  });
});

// ---------------------------------------------------------------------------
// delete — drafts only (Task 4 of docs/superpowers/plans/2026-08-03-inventory.md)
// ---------------------------------------------------------------------------

describe("DELETE /api/c/bills/:id — discard a draft", () => {
  it("throws away a draft and its lines, leaving the ledger untouched", async () => {
    const id = await createVendor("Discard Draft Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "40.00" }, { unitPrice: "60.00" },
    ]));
    const billId = create.json().billId;

    const tbBefore = await get(app, db.companyA, "/api/c/reports/trial-balance");

    const res = await del(app, db.companyA, `/api/c/bills/${billId}`);
    expect(res.statusCode).toBe(200);
    expect(res.json().summary).toMatch(/threw away this draft bill/i);

    const detail = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(detail.statusCode).toBe(404);

    const list = await get(app, db.companyA, "/api/c/bills");
    const ids = list.json().bills.map((b: { billId: string }) => b.billId);
    expect(ids).not.toContain(billId);

    // A draft posts nothing, so discarding one must move nothing.
    const tbAfter = await get(app, db.companyA, "/api/c/reports/trial-balance");
    expect(tbAfter.json().rows).toEqual(tbBefore.json().rows);
  });

  it("frees the (vendor, refNo) it was holding", async () => {
    const id = await createVendor("Discard RefNo Vendor Co");
    const first = await post(app, db.companyA, "/api/c/bills",
      draftBody(id, [{ unitPrice: "10.00" }], { refNo: "RECYCLED-1" }));
    expect(first.statusCode).toBe(201);

    const clash = await post(app, db.companyA, "/api/c/bills",
      draftBody(id, [{ unitPrice: "10.00" }], { refNo: "RECYCLED-1" }));
    expect(clash.statusCode).toBe(409);

    await del(app, db.companyA, `/api/c/bills/${first.json().billId}`);

    const again = await post(app, db.companyA, "/api/c/bills",
      draftBody(id, [{ unitPrice: "10.00" }], { refNo: "RECYCLED-1" }));
    expect(again.statusCode).toBe(201);
  });

  it("409s in English for an issued bill (void it instead)", async () => {
    const id = await createVendor("Discard Issued Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "25.00" },
    ]));
    const billId = create.json().billId;
    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);

    const res = await del(app, db.companyA, `/api/c/bills/${billId}`);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/already in your books/i);
    expect(res.json().error).toMatch(/void it instead/i);

    // Still there, still issued.
    const detail = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(detail.statusCode).toBe(200);
    expect(detail.json().status).toBe("open");
  });

  it("409s in English for an already-voided bill", async () => {
    const id = await createVendor("Discard Voided Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "25.00" },
    ]));
    const billId = create.json().billId;
    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);
    await post(app, db.companyA, `/api/c/bills/${billId}/void`, { date: "2024-06-15" });

    const res = await del(app, db.companyA, `/api/c/bills/${billId}`);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/already been voided/i);
  });

  it("404s for a made-up bill id", async () => {
    const res = await del(app, db.companyA, "/api/c/bills/999999999");
    expect(res.statusCode).toBe(404);
  });

  it("404s (not 500) when the draft belongs to another company (RLS)", async () => {
    const id = await createVendor("Discard RLS Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const billId = create.json().billId;

    const res = await del(app, db.companyB, `/api/c/bills/${billId}`);
    expect(res.statusCode).toBe(404);

    // …and company A's draft is still there.
    const detail = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(detail.statusCode).toBe(200);
  });
});

// ---------------------------------------------------------------------------
// list — derived state
// ---------------------------------------------------------------------------

describe("GET /api/c/bills — list with derived state", () => {
  it("draft, open, and void states are all correctly derived", async () => {
    const id = await createVendor("List State Vendor Co");

    const draftCreate = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const draftId = draftCreate.json().billId;

    const openCreate = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "20.00" },
    ]));
    const openId = openCreate.json().billId;
    await post(app, db.companyA, `/api/c/bills/${openId}/issue`);

    const voidCreate = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "30.00" },
    ]));
    const voidId = voidCreate.json().billId;
    await post(app, db.companyA, `/api/c/bills/${voidId}/issue`);
    await post(app, db.companyA, `/api/c/bills/${voidId}/void`);

    const res = await get(app, db.companyA, `/api/c/bills?vendorId=${id}`);
    expect(res.statusCode).toBe(200);
    const byId = new Map(res.json().bills.map((b: { billId: string }) => [b.billId, b]));

    expect((byId.get(draftId) as { state: string }).state).toBe("draft");
    expect((byId.get(openId) as { state: string; open: string }).state).toBe("open");
    expect((byId.get(openId) as { state: string; open: string }).open).toBe("20.0000");
    expect((byId.get(voidId) as { state: string }).state).toBe("void");
  });

  it("filters by status", async () => {
    const res = await get(app, db.companyA, "/api/c/bills?status=void");
    expect(res.statusCode).toBe(200);
    for (const bill of res.json().bills) {
      expect(bill.state).toBe("void");
    }
  });

  it("400s in English for an invalid status filter", async () => {
    const res = await get(app, db.companyA, "/api/c/bills?status=bogus");
    expect(res.statusCode).toBe(400);
  });
});

// ---------------------------------------------------------------------------
// detail
// ---------------------------------------------------------------------------

describe("GET /api/c/bills/:id — detail", () => {
  it("returns header, vendor, lines, and derived amounts", async () => {
    const id = await createVendor("Detail View Vendor Co", { email: "billing@detailview.example" });
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { description: "Widgets", quantity: "2", unitPrice: "15.00" },
    ]));
    const billId = create.json().billId;
    await post(app, db.companyA, `/api/c/bills/${billId}/issue`);

    const res = await get(app, db.companyA, `/api/c/bills/${billId}`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.vendor.name).toBe("Detail View Vendor Co");
    expect(body.vendor.email).toBe("billing@detailview.example");
    expect(body.lines).toHaveLength(1);
    expect(body.lines[0]).toMatchObject({
      description: "Widgets", quantity: "2.0000", unitPrice: "15.0000", amount: "30.0000",
      categoryAccountName: "Operating Expense",
    });
    expect(body.applications).toEqual([]);
    expect(body.total).toBe("30.0000");
    expect(body.applied).toBe("0.0000");
    expect(body.open).toBe("30.0000");
    expect(body.postedEntryId).not.toBeNull();
  });

  it("404s for a made-up bill id", async () => {
    const res = await get(app, db.companyA, "/api/c/bills/999999999");
    expect(res.statusCode).toBe(404);
  });
});

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

describe("RLS isolation", () => {
  it("company B cannot see or reach company A's bills via GET/:id", async () => {
    const id = await createVendor("RLS Isolation Vendor Co");
    const create = await post(app, db.companyA, "/api/c/bills", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const billId = create.json().billId;

    const detail = await get(app, db.companyB, `/api/c/bills/${billId}`);
    expect(detail.statusCode).toBe(404);

    const list = await get(app, db.companyB, "/api/c/bills");
    const ids = list.json().bills.map((b: { billId: string }) => b.billId);
    expect(ids).not.toContain(billId);
  });
});
