// Integration tests for the AR payments + aging + health API (Task 3 of
// docs/superpowers/plans/2026-07-31-ar.md): receive-payment flows (partial,
// full, multi-invoice, overpay-to-credit, apply-credit-later), void with
// trial-balance cross-checks, the open-invoices picker, the aging report
// (cross-checked against ar_aging() called directly), the dashboard's
// oldestReceivable health item, RLS isolation, and English validation errors
// (incl. the DB's own customer-match / over-application English). Same
// patterns as tests/server/ar-invoices.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 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;
}

/** Creates a draft invoice with one line and immediately issues it. Returns
 *  the invoice id and its assigned invoice number (as strings, per the API). */
async function createIssuedInvoice(
  customerId: string, unitPrice: string, extra: Record<string, unknown> = {},
): Promise<{ invoiceId: string; invoiceNo: string }> {
  const create = await post(app, db.companyA, "/api/c/invoices", {
    customerId,
    invoiceDate: "2024-06-01",
    dueDate: "2024-06-01",
    lines: [{ unitPrice, incomeAccountId: String(db.accounts["4000"]!.A) }],
    ...extra,
  });
  expect(create.statusCode).toBe(201);
  const invoiceId = create.json().invoiceId;
  const issue = await post(app, db.companyA, `/api/c/invoices/${invoiceId}/issue`);
  expect(issue.statusCode).toBe(200);
  return { invoiceId, invoiceNo: issue.json().invoiceNo };
}

async function tbBalance(companyId: string, code: string): Promise<string> {
  const res = await get(app, companyId, "/api/c/reports/trial-balance");
  return res.json().rows.find((r: { code: string }) => r.code === code)?.balance ?? "0.0000";
}

async function invoiceDetail(invoiceId: string) {
  const res = await get(app, db.companyA, `/api/c/invoices/${invoiceId}`);
  return res.json();
}

// ---------------------------------------------------------------------------
// receive payment — partial, full (multi-step), ledger cross-checks
// ---------------------------------------------------------------------------

describe("POST /api/c/payments — receive, partial then full", () => {
  it("partial payment leaves the invoice open with the right applied/open split, cash up / AR down", async () => {
    const custId = await createCustomer("Partial Flow Co");
    const { invoiceId, invoiceNo } = await createIssuedInvoice(custId, "1000.00");

    const cashBefore = await tbBalance(db.companyA, "1000");
    const arBefore = await tbBalance(db.companyA, "1100");

    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "400.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "400.00" }],
    });
    expect(res.statusCode).toBe(201);
    const body = res.json();
    expect(typeof body.paymentId).toBe("string");
    expect(body.summary).toBe(
      `Received $400.00 from Partial Flow Co — applied to invoice #${invoiceNo} ($400.00).`,
    );

    // Cash up 400, AR down 400 (debit-positive: AR's raw balance decreases).
    expect(await tbBalance(db.companyA, "1000")).toBe(addMoney(cashBefore, "400.0000"));
    expect(await tbBalance(db.companyA, "1100")).toBe(addMoney(arBefore, "-400.0000"));

    const detail = await invoiceDetail(invoiceId);
    expect(detail.applied).toBe("400.0000");
    expect(detail.open).toBe("600.0000");

    const list = await get(app, db.companyA, `/api/c/invoices?customerId=${custId}`);
    const row = list.json().invoices.find((i: { invoiceId: string }) => i.invoiceId === invoiceId);
    expect(row.state).toBe("partial");

    // Second payment pays it off in full.
    const cashMid = await tbBalance(db.companyA, "1000");
    const arMid = await tbBalance(db.companyA, "1100");
    const res2 = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-20", amount: "600.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "600.00" }],
    });
    expect(res2.statusCode).toBe(201);

    expect(await tbBalance(db.companyA, "1000")).toBe(addMoney(cashMid, "600.0000"));
    expect(await tbBalance(db.companyA, "1100")).toBe(addMoney(arMid, "-600.0000"));

    const detail2 = await invoiceDetail(invoiceId);
    expect(detail2.open).toBe("0.0000");
    expect(detail2.applied).toBe("1000.0000");

    const list2 = await get(app, db.companyA, `/api/c/invoices?customerId=${custId}`);
    const row2 = list2.json().invoices.find((i: { invoiceId: string }) => i.invoiceId === invoiceId);
    expect(row2.state).toBe("paid");
  });
});

describe("POST /api/c/payments — multi-invoice single payment", () => {
  it("applies one payment across two invoices, paying both off exactly", async () => {
    const custId = await createCustomer("Multi Invoice Co");
    const inv1 = await createIssuedInvoice(custId, "500.00");
    const inv2 = await createIssuedInvoice(custId, "1000.00");

    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-15", amount: "1500.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [
        { invoiceId: inv1.invoiceId, amount: "500.00" },
        { invoiceId: inv2.invoiceId, amount: "1000.00" },
      ],
    });
    expect(res.statusCode).toBe(201);
    expect(res.json().summary).toBe(
      `Received $1,500.00 from Multi Invoice Co — applied to invoices ` +
      `#${inv1.invoiceNo} ($500.00) and #${inv2.invoiceNo} ($1,000.00).`,
    );

    expect((await invoiceDetail(inv1.invoiceId)).open).toBe("0.0000");
    expect((await invoiceDetail(inv2.invoiceId)).open).toBe("0.0000");
  });
});

describe("POST /api/c/payments — overpay becomes credit, applied later", () => {
  it("overpaying an invoice keeps the remainder as unapplied customer credit", async () => {
    const custId = await createCustomer("Overpay Credit Co");
    const { invoiceId, invoiceNo } = await createIssuedInvoice(custId, "300.00");

    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "500.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "300.00" }],
    });
    expect(res.statusCode).toBe(201);
    const paymentId = res.json().paymentId;
    expect(res.json().summary).toBe(
      `Received $500.00 from Overpay Credit Co — applied to invoice #${invoiceNo} ($300.00). ` +
      `$200.00 kept as customer credit.`,
    );

    const picker = await get(app, db.companyA, `/api/c/customers/${custId}/open-invoices`);
    expect(picker.json().unappliedCredit).toBe("200.0000");
    expect(picker.json().invoices).toEqual([]); // the only invoice is now fully paid

    // Apply the remaining credit to a second, later invoice.
    const inv2 = await createIssuedInvoice(custId, "150.00");
    const applyRes = await post(app, db.companyA, `/api/c/payments/${paymentId}/apply`, {
      applications: [{ invoiceId: inv2.invoiceId, amount: "150.00" }],
    });
    expect(applyRes.statusCode).toBe(200);
    expect(applyRes.json().summary).toBe(
      `Applied credit to invoice #${inv2.invoiceNo} ($150.00). $50.00 credit still available.`,
    );

    const detail = await get(app, db.companyA, `/api/c/payments/${paymentId}`);
    expect(detail.json().applied).toBe("450.0000");
    expect(detail.json().unapplied).toBe("50.0000");
    expect((await invoiceDetail(inv2.invoiceId)).open).toBe("0.0000");

    const pickerAfter = await get(app, db.companyA, `/api/c/customers/${custId}/open-invoices`);
    expect(pickerAfter.json().unappliedCredit).toBe("50.0000");
  });

  it("a payment with no applications at all reads as a pure credit", async () => {
    const custId = await createCustomer("Pure Credit Co");
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "75.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [],
    });
    expect(res.statusCode).toBe(201);
    expect(res.json().summary).toBe("Received $75.00 from Pure Credit Co — kept as customer credit.");
  });
});

// ---------------------------------------------------------------------------
// void — restores invoices, restores the ledger
// ---------------------------------------------------------------------------

describe("POST /api/c/payments/:id/void", () => {
  it("reopens the invoice it covered and restores the trial balance", async () => {
    const custId = await createCustomer("Void Restore Payment Co");
    const { invoiceId } = await createIssuedInvoice(custId, "700.00");

    const cashAfterIssue = await tbBalance(db.companyA, "1000");
    const arAfterIssue = await tbBalance(db.companyA, "1100");

    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "700.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "700.00" }],
    });
    const paymentId = payRes.json().paymentId;
    expect((await invoiceDetail(invoiceId)).open).toBe("0.0000");

    const voidRes = await post(app, db.companyA, `/api/c/payments/${paymentId}/void`, { date: "2024-06-15" });
    expect(voidRes.statusCode).toBe(200);
    expect(voidRes.json().summary).toBe("Payment voided — the invoices it covered are open again.");

    // The invoice is open again for the full amount, and the ledger is back
    // to exactly where it was right after the invoice was issued.
    expect((await invoiceDetail(invoiceId)).open).toBe("700.0000");
    expect(await tbBalance(db.companyA, "1000")).toBe(cashAfterIssue);
    expect(await tbBalance(db.companyA, "1100")).toBe(arAfterIssue);

    const detail = await get(app, db.companyA, `/api/c/payments/${paymentId}`);
    expect(detail.json().status).toBe("void");
    // Applications are immutable history — they still show what the payment
    // WAS applied to, even though it no longer counts toward the invoice.
    expect(detail.json().applied).toBe("700.0000");
    expect(detail.json().unapplied).toBe("0.0000");
  });

  it("a voided pure-credit payment reads differently and its credit disappears", async () => {
    const custId = await createCustomer("Void Pure Credit Co");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "100.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [],
    });
    const paymentId = payRes.json().paymentId;

    const voidRes = await post(app, db.companyA, `/api/c/payments/${paymentId}/void`);
    expect(voidRes.statusCode).toBe(200);
    expect(voidRes.json().summary).toBe(
      "Payment voided — the customer credit it created no longer exists.",
    );

    const picker = await get(app, db.companyA, `/api/c/customers/${custId}/open-invoices`);
    expect(picker.json().unappliedCredit).toBe("0.0000");
  });

  it("409s in English when voiding an already-void payment", async () => {
    const custId = await createCustomer("Double Void Co");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });
    const paymentId = payRes.json().paymentId;
    await post(app, db.companyA, `/api/c/payments/${paymentId}/void`);

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

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

// ---------------------------------------------------------------------------
// GET list / detail
// ---------------------------------------------------------------------------

describe("GET /api/c/payments — list and detail", () => {
  it("list includes date, customer, amount, applied/unapplied, status, and an entry link", async () => {
    const custId = await createCustomer("List Shape Co");
    const { invoiceId } = await createIssuedInvoice(custId, "200.00");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "200.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "200.00" }],
    });
    const paymentId = payRes.json().paymentId;

    const list = await get(app, db.companyA, `/api/c/payments?customerId=${custId}`);
    expect(list.statusCode).toBe(200);
    const row = list.json().payments.find((p: { paymentId: string }) => p.paymentId === paymentId);
    expect(row).toMatchObject({
      date: "2024-06-10", customerId: custId, customerName: "List Shape Co",
      amount: "200.0000", applied: "200.0000", unapplied: "0.0000", status: "active",
    });
    expect(typeof row.postedEntryId).toBe("string");
  });

  it("detail includes applications with invoice no/date/amount and an entry link", async () => {
    const custId = await createCustomer("Detail Shape Co");
    const { invoiceId, invoiceNo } = await createIssuedInvoice(custId, "80.00");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "80.00", memo: "check #101",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "80.00" }],
    });
    const paymentId = payRes.json().paymentId;

    const detail = await get(app, db.companyA, `/api/c/payments/${paymentId}`);
    expect(detail.statusCode).toBe(200);
    const body = detail.json();
    expect(body.memo).toBe("check #101");
    expect(body.depositAccountName).toBe("Cash");
    expect(body.applications).toEqual([
      expect.objectContaining({
        invoiceId, invoiceNo, invoiceDate: "2024-06-01", dueDate: "2024-06-01", amount: "80.0000",
      }),
    ]);
    expect(typeof body.postedEntryId).toBe("string");
  });

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

  // Regression coverage (blank-text normalization sweep — Task 3 of
  // docs/superpowers/plans/2026-08-01-ap.md): memoSchema now shares
  // bills.ts's blankToUndefined (server/src/lib/textNormalize.ts), which
  // trims BEFORE checking for blankness. Before this sweep, a whitespace-only
  // memo slipped past the (untrimmed) blankness check and was stored as a
  // literal "" rather than NULL.
  it("a whitespace-only memo stores NULL, not a literal empty string", async () => {
    const custId = await createCustomer("Whitespace Memo Payment Co");
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A), memo: "   ", applications: [],
    });
    expect(res.statusCode).toBe(201);
    const detail = await get(app, db.companyA, `/api/c/payments/${res.json().paymentId}`);
    expect(detail.json().memo).toBeNull();
  });
});

// ---------------------------------------------------------------------------
// open-invoices picker
// ---------------------------------------------------------------------------

describe("GET /api/c/customers/:id/open-invoices", () => {
  it("lists only open invoices with money owed (incl. document_only), excludes paid ones", async () => {
    const custId = await createCustomer("Picker Shape Co");
    const open1 = await createIssuedInvoice(custId, "100.00");
    const openDocOnly = await createIssuedInvoice(custId, "50.00", { documentOnly: true });
    const paid = await createIssuedInvoice(custId, "40.00");
    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "40.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId: paid.invoiceId, amount: "40.00" }],
    });

    const res = await get(app, db.companyA, `/api/c/customers/${custId}/open-invoices`);
    expect(res.statusCode).toBe(200);
    const ids = res.json().invoices.map((i: { invoiceId: string }) => i.invoiceId);
    expect(ids).toContain(open1.invoiceId);
    expect(ids).toContain(openDocOnly.invoiceId);
    expect(ids).not.toContain(paid.invoiceId);

    const row = res.json().invoices.find((i: { invoiceId: string }) => i.invoiceId === open1.invoiceId);
    expect(row).toMatchObject({
      invoiceNo: open1.invoiceNo, invoiceDate: "2024-06-01", dueDate: "2024-06-01",
      total: "100.0000", open: "100.0000",
    });
    expect(typeof row.overdue).toBe("boolean");
    expect(typeof res.json().unappliedCredit).toBe("string");
  });

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

// ---------------------------------------------------------------------------
// aging — bucket math cross-checked against ar_aging() directly
// ---------------------------------------------------------------------------

describe("GET /api/c/reports/aging", () => {
  it("buckets by due date relative to asOf, includes a credit row, matches hand-computed amounts", async () => {
    const custId = await createCustomer("Aging Bucket Co");
    const asOf = "2024-06-01";
    // Days before asOf: current(future), 12, 42, 73, 152 — see the bucket
    // boundaries in db/migrations/0005_ar.sql's ar_aging().
    await createIssuedInvoice(custId, "100.00", { dueDate: "2024-06-10" }); // current
    await createIssuedInvoice(custId, "200.00", { dueDate: "2024-05-20" }); // 1-30
    await createIssuedInvoice(custId, "300.00", { dueDate: "2024-04-20" }); // 31-60
    await createIssuedInvoice(custId, "400.00", { dueDate: "2024-03-20" }); // 61-90
    await createIssuedInvoice(custId, "500.00", { dueDate: "2024-01-01" }); // 90+
    // A pure-credit payment for the same customer.
    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-05-01", amount: "50.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });

    const res = await get(app, db.companyA, `/api/c/reports/aging?asOf=${asOf}`);
    expect(res.statusCode).toBe(200);
    expect(res.json().asOf).toBe(asOf);
    const row = res.json().customers.find((c: { customerId: string }) => c.customerId === custId);
    expect(row.buckets).toEqual({
      current: "100.0000", d1_30: "200.0000", d31_60: "300.0000",
      d61_90: "400.0000", d90_plus: "500.0000",
    });
    expect(row.credits).toBe("50.0000");
    expect(row.total).toBe("1500.0000");

    // Cross-check the report's grand totals against ar_aging() called
    // directly (SQL sum, never JS float math on money) — the whole-company
    // totals reflect every customer/invoice/credit created by every test in
    // this file, so this checks the aggregation, not a fixed magic number.
    const direct = await db.admin.query<{ grand: string; credits: string }>(
      `SELECT coalesce(sum(open_amount) FILTER (WHERE row_type = 'invoice'), 0)::numeric(19,4) AS grand,
              coalesce(sum(open_amount) FILTER (WHERE row_type = 'credit'), 0)::numeric(19,4) AS credits
         FROM ar_aging($1, $2::date)`,
      [db.companyA, asOf],
    );
    expect(res.json().totals.grand).toBe(direct.rows[0]!.grand);
    expect(res.json().totals.credits).toBe(direct.rows[0]!.credits);
  });

  it("defaults asOf to today when omitted", async () => {
    const res = await get(app, db.companyA, "/api/c/reports/aging");
    expect(res.statusCode).toBe(200);
    expect(typeof res.json().asOf).toBe("string");
  });
});

// ---------------------------------------------------------------------------
// aging — POINT IN TIME (migration 0007_ar_aging_point_in_time.sql, owner
// review fix): an "aging as of <date>" report must reconstruct history as it
// stood on that date — never influenced by anything dated, or voided, after
// it — matching how QuickBooks' own aging-as-of works.
// ---------------------------------------------------------------------------

describe("GET /api/c/reports/aging — point in time (migration 0007)", () => {
  it("excludes an invoice dated after asOf, includes it once asOf reaches its own date", async () => {
    const custId = await createCustomer("PIT Invoice Date Co");
    await createIssuedInvoice(custId, "123.45", { invoiceDate: "2024-06-01", dueDate: "2024-06-01" });

    const before = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-05-31");
    expect(before.json().customers.find((c: { customerId: string }) => c.customerId === custId))
      .toBeUndefined();

    const onDate = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-06-01");
    const row = onDate.json().customers.find((c: { customerId: string }) => c.customerId === custId);
    expect(row.total).toBe("123.4500");
  });

  it("a later-dated payment does not retroactively erase an invoice from an earlier report", async () => {
    const custId = await createCustomer("PIT Payment Date Co");
    const { invoiceId } = await createIssuedInvoice(custId, "500.00", {
      invoiceDate: "2024-06-01", dueDate: "2024-06-01",
    });
    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-07-01", amount: "500.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "500.00" }],
    });

    // As of the day before the payment, the invoice must still show OPEN
    // for its pre-payment balance — a payment dated in the future (relative
    // to the report) can't reach back and pay it off early.
    const beforePayment = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-06-30");
    const rowBefore = beforePayment.json().customers.find(
      (c: { customerId: string }) => c.customerId === custId);
    expect(rowBefore.total).toBe("500.0000");

    // As of the payment's own date, it's gone (paid).
    const onPaymentDate = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-07-01");
    expect(onPaymentDate.json().customers.find((c: { customerId: string }) => c.customerId === custId))
      .toBeUndefined();
  });

  it("a payment voided later still counts as applied for a report dated before the void", async () => {
    const custId = await createCustomer("PIT Void Date Co");
    const { invoiceId } = await createIssuedInvoice(custId, "200.00", {
      invoiceDate: "2024-06-01", dueDate: "2024-06-01",
    });
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-07-01", amount: "200.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "200.00" }],
    });
    const paymentId = payRes.json().paymentId;
    const voidRes = await post(app, db.companyA, `/api/c/payments/${paymentId}/void`, { date: "2024-08-01" });
    expect(voidRes.statusCode).toBe(200);

    // As of a date before the void, the payment still counts as applied —
    // the void hadn't happened yet as of that date — so the invoice is gone.
    const beforeVoid = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-07-15");
    expect(beforeVoid.json().customers.find((c: { customerId: string }) => c.customerId === custId))
      .toBeUndefined();

    // As of a date on/after the void, the invoice is open again.
    const afterVoid = await get(app, db.companyA, "/api/c/reports/aging?asOf=2024-08-01");
    const rowAfterVoid = afterVoid.json().customers.find(
      (c: { customerId: string }) => c.customerId === custId);
    expect(rowAfterVoid.total).toBe("200.0000");
  });

  it("asOf = today reproduces the pre-0007 (current-state-only) numbers exactly, on a mixed fixture", async () => {
    const custId = await createCustomer("PIT Today Equivalence Co");
    const paid = await createIssuedInvoice(custId, "300.00", { invoiceDate: "2024-01-01", dueDate: "2024-01-15" });
    const partial = await createIssuedInvoice(custId, "500.00", { invoiceDate: "2024-01-01", dueDate: "2024-01-15" });
    await createIssuedInvoice(custId, "150.00", { invoiceDate: "2024-01-01", dueDate: "2024-01-15" }); // stays fully open

    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-01-10", amount: "300.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId: paid.invoiceId, amount: "300.00" }],
    });
    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-01-10", amount: "200.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId: partial.invoiceId, amount: "200.00" }],
    });
    // a pure, unapplied credit
    await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-01-10", amount: "25.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });

    const apiRes = await get(app, db.companyA, "/api/c/reports/aging"); // asOf omitted -> today
    const row = apiRes.json().customers.find((c: { customerId: string }) => c.customerId === custId);

    // Independent re-derivation of the OLD (pre-0007) status-based logic —
    // status = 'open'/'active' rather than the new as-of/voided_on
    // comparison — scoped to this one customer so it's unaffected by
    // anything any other test in this file has created. All SQL, never JS
    // float math on money.
    const legacy = await db.admin.query<{ grand: string; credits: string }>(
      `SELECT
         coalesce((SELECT sum(invoice_open_balance($1, i.id)) FROM invoices i
                     WHERE i.company_id = $1 AND i.customer_id = $2 AND i.status = 'open'
                       AND invoice_open_balance($1, i.id) > 0), 0)::numeric(19,4) AS grand,
         coalesce((SELECT sum(payment_unapplied_balance($1, p.id)) FROM payments p
                     WHERE p.company_id = $1 AND p.customer_id = $2 AND p.status = 'active'), 0)
           ::numeric(19,4) AS credits`,
      [db.companyA, custId],
    );

    expect(row.total).toBe(legacy.rows[0]!.grand);
    expect(row.credits).toBe(legacy.rows[0]!.credits);
  });
});

// ---------------------------------------------------------------------------
// concurrency (API level) — mirrors tests/invariants/ar.test.ts's DB-level
// "FIX 1" race test, but through the real HTTP route: two simultaneous
// POST /api/c/payments each applying 60 to the same 100 invoice must not
// both succeed (0006_ar_fixes.sql's invoice-locking fix, exercised here via
// two concurrent app.inject() calls hitting separate pooled connections).
// ---------------------------------------------------------------------------

describe("POST /api/c/payments — concurrency", () => {
  it("two concurrent payments applying 60+60 to a 100 invoice: one 201, one English 422, final open 40.00", async () => {
    const custId = await createCustomer("API Concurrency Co");
    const { invoiceId } = await createIssuedInvoice(custId, "100.00");

    const attempt = () => post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "60.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "60.00" }],
    });

    const [r1, r2] = await Promise.all([attempt(), attempt()]);
    const statuses = [r1.statusCode, r2.statusCode].sort();
    expect(statuses).toEqual([201, 422]);

    const failed = r1.statusCode === 422 ? r1 : r2;
    expect(failed.json().error).toMatch(/more than this invoice's balance/i);

    expect((await invoiceDetail(invoiceId)).open).toBe("40.0000");
  });
});

// ---------------------------------------------------------------------------
// dashboard health.oldestReceivable — isolated on company B to avoid
// interference from every other test in this file (all of which use company A).
// ---------------------------------------------------------------------------

describe("GET /api/c/reports/dashboard — health.oldestReceivable", () => {
  async function createIssuedInvoiceB(
    customerId: string, unitPrice: string, dueDate: string,
  ): Promise<{ invoiceId: string; invoiceNo: string }> {
    const create = await post(app, db.companyB, "/api/c/invoices", {
      customerId, invoiceDate: "2024-01-01", dueDate,
      lines: [{ unitPrice, incomeAccountId: String(db.accounts["4000"]!.B) }],
    });
    expect(create.statusCode).toBe(201);
    const invoiceId = create.json().invoiceId;
    const issue = await post(app, db.companyB, `/api/c/invoices/${invoiceId}/issue`);
    expect(issue.statusCode).toBe(200);
    return { invoiceId, invoiceNo: issue.json().invoiceNo };
  }

  it("is null when there are no open receivables", async () => {
    // Runs first, before any invoice in this describe block exists — company
    // B is untouched by every other describe in this file (they all use
    // company A), so this is a clean "nothing yet" assertion.
    const res = await get(app, db.companyB, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    expect(res.json().health.oldestReceivable).toBeNull();
  });

  it("picks the OLDEST open invoice by due date, ignoring a paid one that's older still", async () => {
    const custId = await post(app, db.companyB, "/api/c/customers", { name: "Health Co" })
      .then((r) => { expect(r.statusCode).toBe(201); return r.json().id; });

    // Oldest of all by due date, but already paid off in full — must be excluded.
    const paidOldest = await createIssuedInvoiceB(custId, "10.00", "2000-01-01");
    await post(app, db.companyB, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-01-05", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.B),
      applications: [{ invoiceId: paidOldest.invoiceId, amount: "10.00" }],
    });

    // Still open, and older (earlier due date) than the next one below.
    const oldestOpen = await createIssuedInvoiceB(custId, "999.00", "2019-01-01");
    // Still open, but not as old.
    await createIssuedInvoiceB(custId, "50.00", "2022-01-01");

    const daysRes = await db.admin.query<{ days: number }>(
      "SELECT (current_date - '2019-01-01'::date)::int AS days",
    );
    const expectedDays = Math.max(daysRes.rows[0]!.days, 0);

    const res = await get(app, db.companyB, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    expect(res.json().health.oldestReceivable).toEqual({
      invoiceId: oldestOpen.invoiceId,
      invoiceNo: oldestOpen.invoiceNo,
      customerName: "Health Co",
      dueDate: "2019-01-01",
      daysOverdue: expectedDays,
      open: "999.0000",
    });
  });
});

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

describe("RLS isolation", () => {
  it("company B cannot see, void, or reach company A's payments", async () => {
    const custId = await createCustomer("RLS Payment Co");
    const { invoiceId } = await createIssuedInvoice(custId, "60.00");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "60.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "60.00" }],
    });
    const paymentId = payRes.json().paymentId;

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

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

    const voidRes = await post(app, db.companyB, `/api/c/payments/${paymentId}/void`);
    expect(voidRes.statusCode).toBe(404);
  });
});

// ---------------------------------------------------------------------------
// validation — English 400/422s, incl. the DB's own customer-match message
// ---------------------------------------------------------------------------

describe("POST /api/c/payments — validation", () => {
  it.each(["12.345", "-5", "abc", "0", "007"])("400s for an invalid amount %s", async (amount) => {
    const custId = await createCustomer("Bad Amount Co " + amount);
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount,
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });
    expect(res.statusCode).toBe(400);
  });

  it("422s in English when the deposit account is not a money (asset) account", async () => {
    const custId = await createCustomer("Bad Deposit Account Co");
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["4000"]!.A), applications: [],
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/money account/i);
  });

  it("404s in English for an unknown customerId", async () => {
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: "999999999", paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });
    expect(res.statusCode).toBe(404);
    expect(res.json().error).toMatch(/customer/i);
  });

  it("422s with the DB's own English when an application's invoice belongs to a different customer", async () => {
    const custA = await createCustomer("Cross Customer A");
    const custB = await createCustomer("Cross Customer B");
    const { invoiceId } = await createIssuedInvoice(custB, "50.00");

    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custA, paymentDate: "2024-06-10", amount: "50.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "50.00" }],
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toBe("That invoice doesn't belong to this customer.");
  });

  it("422s in English when only a draft (unissued) invoice is targeted", async () => {
    const custId = await createCustomer("Draft Target Co");
    const create = await post(app, db.companyA, "/api/c/invoices", {
      customerId: custId, invoiceDate: "2024-06-01",
      lines: [{ unitPrice: "10.00", incomeAccountId: String(db.accounts["4000"]!.A) }],
    });
    const invoiceId = create.json().invoiceId;

    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "10.00" }],
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/issued, unpaid invoice/i);
  });

  it("422s in English when an application would over-apply the invoice", async () => {
    const custId = await createCustomer("Invoice Overapply API Co");
    const { invoiceId } = await createIssuedInvoice(custId, "50.00");
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "100.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId, amount: "100.00" }],
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/more than this invoice's balance/i);
  });

  it("422s in English when applications sum to more than the payment's own amount", async () => {
    const custId = await createCustomer("Payment Overapply API Co");
    const inv1 = await createIssuedInvoice(custId, "1000.00");
    const res = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "50.00",
      depositAccountId: String(db.accounts["1000"]!.A),
      applications: [{ invoiceId: inv1.invoiceId, amount: "100.00" }],
    });
    expect(res.statusCode).toBe(422);
    expect(res.json().error).toMatch(/more than this payment covers/i);
  });

  it("400s for an apply request with no applications", async () => {
    const custId = await createCustomer("Empty Apply Co");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });
    const res = await post(app, db.companyA, `/api/c/payments/${payRes.json().paymentId}/apply`, {
      applications: [],
    });
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/at least one invoice/i);
  });

  it("409s in English when applying credit from a voided payment", async () => {
    const custId = await createCustomer("Apply After Void Co");
    const payRes = await post(app, db.companyA, "/api/c/payments", {
      customerId: custId, paymentDate: "2024-06-10", amount: "10.00",
      depositAccountId: String(db.accounts["1000"]!.A), applications: [],
    });
    const paymentId = payRes.json().paymentId;
    await post(app, db.companyA, `/api/c/payments/${paymentId}/void`);

    const inv = await createIssuedInvoice(custId, "10.00");
    const res = await post(app, db.companyA, `/api/c/payments/${paymentId}/apply`, {
      applications: [{ invoiceId: inv.invoiceId, amount: "10.00" }],
    });
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toMatch(/voided and can't be used/i);
  });
});

// ---------------------------------------------------------------------------
// money-string helper (test-only; never used to compute anything the API
// itself asserts on beyond these before/after deltas) — plain integer-cents
// arithmetic, same technique as server/src/invoices.ts's toMicros/fromMicros,
// never a parsed JS float.
// ---------------------------------------------------------------------------
function addMoney(a: string, b: string): string {
  const toMicros = (s: string): bigint => {
    const negative = s.startsWith("-");
    const unsigned = negative ? s.slice(1) : s;
    const [wholeRaw, fracRaw = ""] = unsigned.split(".");
    const frac = (fracRaw + "0000").slice(0, 4);
    const v = BigInt(wholeRaw || "0") * 10000n + BigInt(frac || "0");
    return negative ? -v : v;
  };
  const sum = toMicros(a) + toMicros(b);
  const negative = sum < 0n;
  const abs = negative ? -sum : sum;
  const whole = abs / 10000n;
  const fracPart = (abs % 10000n).toString().padStart(4, "0");
  return `${negative ? "-" : ""}${whole}.${fracPart}`;
}
