// Integration tests for the AR customers + invoices API (Task 2 of
// docs/superpowers/plans/2026-07-31-ar.md): customer CRUD + unique-name
// conflict, draft invoice 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/print payloads, RLS isolation, and English validation errors. Same
// patterns as tests/server/entries.test.ts (createTestDb, app.inject, the
// seeded chart of accounts).

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 } });
}

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 createCustomer(name: string, extra: Record<string, unknown> = {}): Promise<string> {
  const res = await post(app, db.companyA, "/api/c/customers", { name, ...extra });
  expect(res.statusCode).toBe(201);
  return res.json().id;
}

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

// ---------------------------------------------------------------------------
// customers
// ---------------------------------------------------------------------------

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

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

  it("400s in English for a missing name", async () => {
    const res = await post(app, db.companyA, "/api/c/customers", {});
    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/customers", {
      name: "Bad Email 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 createCustomer("Duplicate Name Co");
    const res = await post(app, db.companyA, "/api/c/customers", { name: "Duplicate Name 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/customers", { name: "Duplicate Name Co" });
    expect(res.statusCode).toBe(201);
  });

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

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

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

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

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

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

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

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

describe("POST /api/c/invoices — draft creation", () => {
  it("creates a draft with default quantity 1 and dueDate = invoiceDate + termsDays", async () => {
    const id = await createCustomer("Default Terms Co", { termsDays: 20 });
    const res = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "250.00", description: "Consulting" },
    ]));
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.status).toBe("draft");
    expect(body.documentOnly).toBe(false);
    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: "Consulting", quantity: "1", unitPrice: "250.00", amount: "250.0000",
    });
  });

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

  it("computes amount = quantity * unitPrice exactly (3 x 33.33 = 99.99)", async () => {
    const id = await createCustomer("Exact Math Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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/invoices.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 createCustomer("Half Up Rounding Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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/invoices.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 createCustomer("Overflow Qty Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 createCustomer("Overflow Price Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 createCustomer("Overflow Product Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 createCustomer("Qty Digit Bound Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 createCustomer("Price Digit Bound Co");
    const res = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "12345678901234.00" }, // 14 digits
    ]));
    expect(res.statusCode).toBe(400);
  });

  it("sums multiple lines into the invoice total", async () => {
    const id = await createCustomer("Multi Line Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 createCustomer("Doc Only Draft Co");
    const res = await post(app, db.companyA, "/api/c/invoices", 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 customerId", async () => {
    const res = await post(app, db.companyA, "/api/c/invoices", draftBody("999999999", [
      { unitPrice: "10.00" },
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/customer/i);
  });

  it("422s in English when a line's incomeAccountId is not an income account", async () => {
    const id = await createCustomer("Bad Line Account Co");
    const res = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "10.00", incomeAccountId: String(db.accounts["1000"]!.A) }, // Cash — asset
    ]));
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/income category/i);
  });

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

  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 createCustomer("Bad Price Co " + unitPrice);
      const res = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [{ unitPrice }]));
      expect(res.statusCode).toBe(400);
    },
  );
});

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

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

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

  // Regression coverage (blank-text normalization sweep — Task 3 of
  // docs/superpowers/plans/2026-08-01-ap.md): memoSchema/descriptionSchema
  // now share bills.ts's blankToUndefined (server/src/lib/textNormalize.ts),
  // which trims BEFORE checking for blankness. Before this sweep, a
  // whitespace-only memo/description slipped past the (untrimmed) blankness
  // check and was stored as a literal "" rather than NULL.
  it("a whitespace-only memo/line-description stores NULL, not a literal empty string", async () => {
    const id = await createCustomer("Whitespace Memo Co");
    const res = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "10.00", description: "\t \n" },
    ], { memo: "   " }));
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(body.memo).toBeNull();
    expect(body.lines[0].description).toBeNull();

    const detail = await get(app, db.companyA, `/api/c/invoices/${body.invoiceId}`);
    expect(detail.json().memo).toBeNull();
  });

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

    const res = await patch(app, db.companyA, `/api/c/invoices/${invoiceId}`, 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);
  });
});

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

describe("POST /api/c/invoices/:id/issue", () => {
  it("assigns an invoice number, posts a balanced entry, and the summary reads naturally", async () => {
    const id = await createCustomer("Issue Cross-Check Co");
    const create = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "1234.00" },
    ]));
    const invoiceId = create.json().invoiceId;

    const res = await post(app, db.companyA, `/api/c/invoices/${invoiceId}/issue`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(typeof body.invoiceNo).toBe("string");
    expect(Number(body.invoiceNo)).toBeGreaterThan(0);
    expect(body.summary).toBe(`Invoice #${body.invoiceNo} for $1,234.00 sent to the books.`);

    // Ledger cross-check: AR account drill-through shows the invoice total,
    // and the income account was credited for the same amount.
    const arEntries = await get(app, db.companyA, `/api/c/accounts/${db.accounts["1100"]!.A}/entries`);
    const arRow = arEntries.json().entries.find((e: { memo: string | null }) =>
      e.memo === `Invoice #${body.invoiceNo}`);
    expect(arRow).toBeTruthy();
    expect(arRow.amountIntoAccount).toBe("1234.0000");

    const incomeEntries = await get(app, db.companyA, `/api/c/accounts/${db.accounts["4000"]!.A}/entries`);
    const incomeRow = incomeEntries.json().entries.find((e: { memo: string | null }) =>
      e.memo === `Invoice #${body.invoiceNo}`);
    expect(incomeRow).toBeTruthy();
    expect(incomeRow.amountIntoAccount).toBe("1234.0000");
  });

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

    const res = await post(app, db.companyA, `/api/c/invoices/${invoiceId}/issue`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(Number(body.invoiceNo)).toBeGreaterThan(0);
    expect(body.summary).toBe(
      `Invoice #${body.invoiceNo} for $2,789.80 recorded for tracking only — no accounting impact.`,
    );

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

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

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

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

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

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

describe("POST /api/c/invoices/:id/void", () => {
  it("restores the AR account balance and marks the invoice void", async () => {
    const id = await createCustomer("Void Restore Co");
    const create = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "300.00" },
    ]));
    const invoiceId = create.json().invoiceId;

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

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

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

    const detail = await get(app, db.companyA, `/api/c/invoices/${invoiceId}`);
    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 createCustomer("Void Draft Co");
    const create = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { unitPrice: "10.00" },
    ]));
    const res = await post(app, db.companyA, `/api/c/invoices/${create.json().invoiceId}/void`);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/issued/i);
  });

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

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

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

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

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

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

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

    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/invoices?status=void");
    expect(res.statusCode).toBe(200);
    for (const inv of res.json().invoices) {
      expect(inv.state).toBe("void");
    }
  });

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

// ---------------------------------------------------------------------------
// detail + print
// ---------------------------------------------------------------------------

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

    const res = await get(app, db.companyA, `/api/c/invoices/${invoiceId}`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.customer.name).toBe("Detail View Co");
    expect(body.customer.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",
      incomeAccountName: "Revenue",
    });
    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 invoice id", async () => {
    const res = await get(app, db.companyA, "/api/c/invoices/999999999");
    expect(res.statusCode).toBe(404);
  });
});

describe("GET /api/c/invoices/:id/print", () => {
  it("returns the printable payload", async () => {
    const id = await createCustomer("Print View Co", { address: "123 Print Ln" });
    const create = await post(app, db.companyA, "/api/c/invoices", draftBody(id, [
      { description: "Service", unitPrice: "500.00" },
    ]));
    const invoiceId = create.json().invoiceId;
    await post(app, db.companyA, `/api/c/invoices/${invoiceId}/issue`);

    const res = await get(app, db.companyA, `/api/c/invoices/${invoiceId}/print`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.company.name).toBeTruthy();
    expect(body.customer).toMatchObject({ name: "Print View Co", address: "123 Print Ln" });
    expect(body.invoice.no).toBeTruthy();
    expect(body.lines).toEqual([
      // taxable joined the payload in the sales tax milestone (0015) — false
      // on every untaxed document.
      { description: "Service", quantity: "1.0000", unitPrice: "500.0000", amount: "500.0000", taxable: false },
    ]);
    expect(body.total).toBe("500.0000");
    expect(body.balanceDue).toBe("500.0000");
    expect(typeof body.termsDays).toBe("number");
  });
});

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

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

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

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