// Integration tests for the read-only reports API (Task 3 of
// docs/superpowers/plans/2026-07-31-local-app.md). Boots the app builder
// against a throwaway DB (tests/helpers/db.ts), seeds a small but
// non-trivial ledger for company A across two calendar years (including one
// fully-reversed entry and one entry with import provenance), then checks
// every endpoint's numbers against hand-computed expectations AND against
// trial_balance()/ledger_out_of_balance() called directly — the same
// pattern tests/invariants/reporting.test.ts uses. Company B (seeded empty
// by createTestDb) is the RLS check: its reports must show nothing of
// company A's, not just "different" numbers.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestDb, type TestDb, insertDraftEntry, postEntry } 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";
import type { Client } from "pg";

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

function inject(app: FastifyInstance, companyId: string, url: string) {
  return app.inject({ method: "GET", url, headers: { "x-company-id": companyId } });
}

let db: TestDb;
let app: FastifyInstance;
let cA: Client;

// Fixture entry ids (company A), assigned in beforeAll.
let entry1Id: number; // 2024-01-10: AR 1000 debit / Revenue 1000 credit — "Invoice #1", staged w/ a name
let entry2Id: number; // 2024-01-20: Cash 600 debit / AR 600 credit — collected invoice #1
let entry3Id: number; // 2024-02-05: Expense 250 debit / Cash 250 credit
let entry4Id: number; // 2025-01-05: AR 500 debit / Revenue 500 credit — "Invoice #2", never reversed
let entry5Id: number; // 2025-03-01: Expense 100 debit / Cash 100 credit — reversed below
let reversal5Id: number; // 2025-03-15: reversal of entry5

beforeAll(async () => {
  db = await createTestDb();
  app = await buildTestApp(db.dbName);
  cA = await db.appClient(db.companyA);

  entry1Id = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2024-01-10", memo: "Invoice #1",
    lines: [
      { accountId: db.accounts["1100"].A, debit: "1000.0000" },
      { accountId: db.accounts["4000"].A, credit: "1000.0000" },
    ],
  });
  await postEntry(cA, db.companyA, entry1Id);

  entry2Id = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2024-01-20", memo: "Collected invoice #1",
    lines: [
      { accountId: db.accounts["1000"].A, debit: "600.0000" },
      { accountId: db.accounts["1100"].A, credit: "600.0000" },
    ],
  });
  await postEntry(cA, db.companyA, entry2Id);

  entry3Id = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2024-02-05", memo: "Office supplies",
    lines: [
      { accountId: db.accounts["6000"].A, debit: "250.0000" },
      { accountId: db.accounts["1000"].A, credit: "250.0000" },
    ],
  });
  await postEntry(cA, db.companyA, entry3Id);

  entry4Id = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2025-01-05", memo: "Invoice #2",
    lines: [
      { accountId: db.accounts["1100"].A, debit: "500.0000" },
      { accountId: db.accounts["4000"].A, credit: "500.0000" },
    ],
  });
  await postEntry(cA, db.companyA, entry4Id);

  entry5Id = await insertDraftEntry(cA, db.companyA, db.userA, {
    date: "2025-03-01", memo: "Contractor (billed in error)",
    lines: [
      { accountId: db.accounts["6000"].A, debit: "100.0000" },
      { accountId: db.accounts["1000"].A, credit: "100.0000" },
    ],
  });
  await postEntry(cA, db.companyA, entry5Id);

  const rev = await cA.query<{ reverse_entry: string }>(
    "SELECT reverse_entry($1, $2, $3, $4)", [db.companyA, entry5Id, "2025-03-15", db.userA],
  );
  reversal5Id = Number(rev.rows[0]!.reverse_entry);

  // Import provenance fixture: entry1 "came from" a staged QuickBooks import
  // row, so dashboard.activity[].who and /entries/:id's importBatch have
  // something real to resolve (mirrors what post_batch() links in practice —
  // see db/migrations/0003_import_layer.sql).
  const batch = await db.admin.query<{ id: string }>(
    `INSERT INTO import_batches (company_id, source, filename, file_sha256, status, created_by)
     VALUES ($1, 'quickbooks_journal', 'journal-2024.csv', 'deadbeef', 'posted', $2)
     RETURNING id`,
    [db.companyA, db.userA],
  );
  await db.admin.query(
    `INSERT INTO staging_journal
       (company_id, batch_id, entry_key, name, memo, account_raw, raw,
        resolved_account_id, parsed_date, parsed_debit, status, posted_entry_id)
     VALUES ($1, $2, '1', 'Acme Customer', 'Invoice #1', 'Accounts Receivable', '{}'::jsonb,
             $3, '2024-01-10', 1000.0000, 'posted', $4)`,
    [db.companyA, batch.rows[0]!.id, db.accounts["1100"].A, entry1Id],
  );
}, 60_000);

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

describe("GET /api/c/reports/dashboard", () => {
  it("matches hand-computed totals for company A", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    const body = res.json();

    expect(body.health).toEqual({
      outOfBalance: "0.0000", balanced: true, wrongSign: [], oldestReceivable: null,
      overdueBills: { count: 0, total: "0.0000" }, billsDueSoon: { count: 0, total: "0.0000" },
      // Bank Rec Task 5: Cash (1000) and Accounts Receivable (1100) both
      // have posted activity above and have never been reconciled; Due from
      // Affiliate (1200) is untouched by this describe block so it's
      // excluded (no entries yet — nothing to reconcile).
      neverReconciled: [
        { accountId: String(db.accounts["1000"]!.A), name: "Cash" },
        { accountId: String(db.accounts["1100"]!.A), name: "Accounts Receivable" },
      ],
      staleReconciliation: [],
      // Inventory's ledger_out_of_balance(). This company keeps no stock, so
      // check_inventory_ties() has no rows to sum and the answer is a flat
      // 0.0000 — the health panel says nothing about a feature nobody uses.
      inventoryOutOfTie: "0.0000",
      inventoryTies: true,
    });

    // Cash 350 + AR 900 (Revenue/Expense/Liabilities/Equity untouched or
    // fully reversed — see the beforeAll comment for the full derivation).
    expect(body.tiles.youHave).toBe("1250.0000");
    expect(body.tiles.youOwe).toBe("0.0000");
    // No fixture data falls in the real current year.
    expect(body.tiles.yearMoneyIn).toBe("0.0000");
    expect(body.tiles.yearProfit).toBe("0.0000");
    // Net worth = youHave - youOwe = 1250.0000 - 0.0000, computed in SQL (never JS float math on money).
    expect(body.tiles.netWorth).toBe("1250.0000");

    expect(body.years).toEqual([
      { year: 2024, moneyIn: "1000.0000", moneyOut: "250.0000" },
      { year: 2025, moneyIn: "500.0000", moneyOut: "0.0000" },
    ]);

    expect(body.balanceSheet.assets).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ name: "Cash", balance: "350.0000" }),
        expect.objectContaining({ name: "Accounts Receivable", balance: "900.0000" }),
      ]),
    );
    expect(body.balanceSheet.assets).toHaveLength(2);
    expect(body.balanceSheet.liabilities).toEqual([]);
    expect(body.balanceSheet.equityTotal).toBe("0.0000");

    expect(body.activity).toHaveLength(6);
    const [rev5, e5, e4, e3, e2, e1] = body.activity;
    expect(rev5).toMatchObject({ entryId: String(reversal5Id), amount: "100.0000", reversed: false, who: null });
    expect(e5).toMatchObject({ entryId: String(entry5Id), amount: "100.0000", reversed: true, who: null });
    expect(e4).toMatchObject({ entryId: String(entry4Id), amount: "500.0000", reversed: false, who: null });
    expect(e3).toMatchObject({ entryId: String(entry3Id), amount: "250.0000", reversed: false, who: null });
    expect(e2).toMatchObject({ entryId: String(entry2Id), amount: "600.0000", reversed: false, who: null });
    expect(e1).toMatchObject({
      entryId: String(entry1Id), amount: "1000.0000", reversed: false, who: "Acme Customer",
    });
  });

  it("cross-checks tiles.youHave against trial_balance() directly", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/dashboard");
    const body = res.json();
    const tb = await cA.query<{ balance: string }>(
      `SELECT sum(balance)::numeric(19,4) AS balance FROM trial_balance($1, NULL) WHERE type = 'asset'`,
      [db.companyA],
    );
    expect(body.tiles.youHave).toBe(tb.rows[0]!.balance);
  });

  it("shows nothing for company B (RLS, not just 'different' numbers)", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.health).toEqual({
      outOfBalance: "0.0000", balanced: true, wrongSign: [], oldestReceivable: null,
      overdueBills: { count: 0, total: "0.0000" }, billsDueSoon: { count: 0, total: "0.0000" },
      neverReconciled: [], staleReconciliation: [],
      inventoryOutOfTie: "0.0000", inventoryTies: true,
    });
    expect(body.tiles).toEqual({
      youHave: "0.0000", youOwe: "0.0000", yearMoneyIn: "0.0000", yearProfit: "0.0000",
      netWorth: "0.0000",
    });
    expect(body.years).toEqual([]);
    expect(body.balanceSheet).toEqual({ assets: [], liabilities: [], equityTotal: "0.0000" });
    expect(body.activity).toEqual([]);
  });
});

describe("GET /api/c/reports/trial-balance", () => {
  it("returns every account verbatim (debit-positive, raw) with matching totals", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/trial-balance");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.asOf).toBeNull();

    const byCode = new Map(body.rows.map((r: { code: string }) => [r.code, r]));
    expect(byCode.get("1000")).toMatchObject({ debit: "700.0000", credit: "350.0000", balance: "350.0000" });
    expect(byCode.get("1100")).toMatchObject({ debit: "1500.0000", credit: "600.0000", balance: "900.0000" });
    expect(byCode.get("4000")).toMatchObject({ balance: "-1500.0000" }); // debit-positive storage
    expect(byCode.get("6000")).toMatchObject({ balance: "250.0000" });
    expect(body.rows).toHaveLength(8); // the full seeded chart, zero-balance accounts included

    expect(body.totals.debit).toBe(body.totals.credit); // ledger_out_of_balance() === 0
  });

  it("honors asOf as a cutoff (excludes entry4, dated 2025-01-05)", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/trial-balance?asOf=2024-12-31");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.asOf).toBe("2024-12-31");
    const byCode = new Map(body.rows.map((r: { code: string }) => [r.code, r]));
    expect(byCode.get("1100")).toMatchObject({ balance: "400.0000" });
    expect(byCode.get("4000")).toMatchObject({ balance: "-1000.0000" });
  });

  it("rejects a malformed asOf with 400 and an English message", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/trial-balance?asOf=not-a-date");
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/date/i);
  });

  it("shows only zero-balance rows for company B", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/trial-balance");
    const body = res.json();
    expect(body.rows).toHaveLength(8);
    expect(body.rows.every((r: { balance: string }) => r.balance === "0.0000")).toBe(true);
    expect(body.totals).toEqual({ debit: "0.0000", credit: "0.0000" });
  });
});

describe("GET /api/c/reports/pnl", () => {
  it("full history: income/expense totals match the P&L identity", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/pnl");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.income).toEqual([
      expect.objectContaining({ name: "Revenue", amount: "1500.0000" }),
    ]);
    expect(body.incomeTotal).toBe("1500.0000");
    expect(body.expense).toEqual([
      expect.objectContaining({ name: "Operating Expense", amount: "250.0000" }),
    ]);
    expect(body.expenseTotal).toBe("250.0000");
    expect(body.net).toBe("1250.0000");
  });

  it("filters to a period, distinct from full history (2024 vs 2025)", async () => {
    const res2024 = await inject(app, db.companyA, "/api/c/reports/pnl?from=2024-01-01&to=2024-12-31");
    const body2024 = res2024.json();
    expect(body2024.incomeTotal).toBe("1000.0000");
    expect(body2024.expenseTotal).toBe("250.0000");
    expect(body2024.net).toBe("750.0000");

    const res2025 = await inject(app, db.companyA, "/api/c/reports/pnl?from=2025-01-01&to=2025-12-31");
    const body2025 = res2025.json();
    expect(body2025.incomeTotal).toBe("500.0000");
    // entry5 + its reversal both fall in 2025 and cancel to zero net, but the
    // account still appears (nonzero gross activity) with a zero amount.
    expect(body2025.expenseTotal).toBe("0.0000");
    expect(body2025.net).toBe("500.0000");
  });

  it("rejects a malformed date range with 400", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/pnl?from=2024-13-40");
    expect(res.statusCode).toBe(400);
  });

  it("is empty for company B", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/pnl");
    const body = res.json();
    expect(body.income).toEqual([]);
    expect(body.expense).toEqual([]);
    expect(body.net).toBe("0.0000");
  });
});

describe("GET /api/c/reports/balance-sheet", () => {
  it("assets = liabilities + equity (incl. Accumulated profit), full history", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/balance-sheet");
    expect(res.statusCode).toBe(200);
    const body = res.json();

    expect(body.assetsTotal).toBe("1250.0000");
    expect(body.liabilitiesTotal).toBe("0.0000");
    expect(body.equity).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ accountId: null, name: "Accumulated profit", balance: "1250.0000" }),
      ]),
    );
    // Opening Equity itself is untouched (zero) so it's excluded from the list.
    expect(body.equity).toHaveLength(1);
    expect(body.equityTotal).toBe("1250.0000");
    expect(body.totalLiabilitiesAndEquity).toBe(body.assetsTotal); // the identity, verified
  });

  it("holds the identity at an interim asOf too (2024-12-31, before entry4/entry5)", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/balance-sheet?asOf=2024-12-31");
    const body = res.json();
    // Cash 350 (entry2/entry3) + AR 400 (entry1/entry2) — entry4/entry5/reversal5 excluded.
    expect(body.assetsTotal).toBe("750.0000");
    expect(body.totalLiabilitiesAndEquity).toBe(body.assetsTotal);
  });

  it("shows an all-zero, identity-holding sheet for company B", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/balance-sheet");
    const body = res.json();
    expect(body.assets).toEqual([]);
    expect(body.liabilities).toEqual([]);
    expect(body.assetsTotal).toBe("0.0000");
    // No equity accounts have any activity, but the computed accumulated-
    // profit line is always shown (even at zero) rather than disappearing.
    expect(body.equity).toEqual([
      { accountId: null, code: null, name: "Accumulated profit", balance: "0.0000" },
    ]);
    expect(body.equityTotal).toBe("0.0000");
    expect(body.totalLiabilitiesAndEquity).toBe("0.0000");
  });
});

describe("GET /api/c/accounts/:id/entries (drill-through)", () => {
  it("returns entries touching the account, signed from its own perspective", async () => {
    const res = await inject(app, db.companyA, `/api/c/accounts/${db.accounts["1100"].A}/entries`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.total).toBe(3);
    expect(body.entries.map((e: { entryId: string }) => e.entryId)).toEqual([
      String(entry4Id), String(entry2Id), String(entry1Id),
    ]);
    expect(body.entries[0]).toMatchObject({ amountIntoAccount: "500.0000" }); // entry4: debit increases AR
    expect(body.entries[1]).toMatchObject({ amountIntoAccount: "-600.0000" }); // entry2: credit decreases AR
    expect(body.entries[2]).toMatchObject({ amountIntoAccount: "1000.0000", who: "Acme Customer" });
  });

  it("paginates with limit/offset", async () => {
    const res = await inject(
      app, db.companyA, `/api/c/accounts/${db.accounts["1100"].A}/entries?limit=2&offset=0`,
    );
    const body = res.json();
    expect(body.total).toBe(3);
    expect(body.entries).toHaveLength(2);

    const res2 = await inject(
      app, db.companyA, `/api/c/accounts/${db.accounts["1100"].A}/entries?limit=2&offset=2`,
    );
    expect(res2.json().entries).toHaveLength(1);
  });

  it("filters by date range", async () => {
    const res = await inject(
      app, db.companyA,
      `/api/c/accounts/${db.accounts["1100"].A}/entries?from=2024-01-01&to=2024-12-31`,
    );
    const body = res.json();
    expect(body.total).toBe(2);
    expect(body.entries.map((e: { entryId: string }) => e.entryId)).toEqual([
      String(entry2Id), String(entry1Id),
    ]);
  });

  it("404s for an unknown account id", async () => {
    const res = await inject(app, db.companyA, "/api/c/accounts/999999999/entries");
    expect(res.statusCode).toBe(404);
  });

  it("404s for a cross-company account id (RLS makes it look unknown)", async () => {
    const res = await inject(app, db.companyB, `/api/c/accounts/${db.accounts["1100"].A}/entries`);
    expect(res.statusCode).toBe(404);
  });

  it("400s for a non-numeric account id", async () => {
    const res = await inject(app, db.companyA, "/api/c/accounts/abc/entries");
    expect(res.statusCode).toBe(400);
  });

  it("400s (not 500) for an account id too long to be a bigint", async () => {
    // 19 nines exceeds bigint's max (9223372036854775807, 19 digits) — this
    // must fail zod validation with an English message, not blow up as a
    // Postgres "bigint out of range" 500.
    const res = await inject(app, db.companyA, "/api/c/accounts/9999999999999999999/entries");
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/whole number/i);
  });

  it("400s for an out-of-range limit", async () => {
    const res = await inject(app, db.companyA, `/api/c/accounts/${db.accounts["1100"].A}/entries?limit=0`);
    expect(res.statusCode).toBe(400);
  });
});

describe("GET /api/c/entries/:id", () => {
  it("shows a plain entry with import provenance", async () => {
    const res = await inject(app, db.companyA, `/api/c/entries/${entry1Id}`);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.memo).toBe("Invoice #1");
    expect(body.status).toBe("posted");
    expect(body.importBatch).toEqual({ filename: "journal-2024.csv" });
    expect(body.reversesEntryId).toBeNull();
    expect(body.reversedByEntryId).toBeNull();

    const ar = body.lines.find((l: { code: string }) => l.code === "1100");
    const rev = body.lines.find((l: { code: string }) => l.code === "4000");
    expect(ar).toMatchObject({ side: "in", amount: "1000.0000", debit: "1000.0000", credit: "0.0000" });
    expect(rev).toMatchObject({ side: "in", amount: "1000.0000", debit: "0.0000", credit: "1000.0000" });
  });

  it("shows reversal linkage on both sides of a reversed pair", async () => {
    const original = (await inject(app, db.companyA, `/api/c/entries/${entry5Id}`)).json();
    expect(original.reversesEntryId).toBeNull();
    expect(original.reversedByEntryId).toBe(String(reversal5Id));
    const expenseLine = original.lines.find((l: { code: string }) => l.code === "6000");
    const cashLine = original.lines.find((l: { code: string }) => l.code === "1000");
    expect(expenseLine).toMatchObject({ side: "in", amount: "100.0000" }); // debit increases expense
    expect(cashLine).toMatchObject({ side: "out", amount: "100.0000" }); // credit decreases cash

    const reversal = (await inject(app, db.companyA, `/api/c/entries/${reversal5Id}`)).json();
    expect(reversal.reversesEntryId).toBe(String(entry5Id));
    expect(reversal.reversedByEntryId).toBeNull();
    const revExpenseLine = reversal.lines.find((l: { code: string }) => l.code === "6000");
    const revCashLine = reversal.lines.find((l: { code: string }) => l.code === "1000");
    expect(revExpenseLine).toMatchObject({ side: "out", amount: "100.0000" }); // credit decreases expense
    expect(revCashLine).toMatchObject({ side: "in", amount: "100.0000" }); // debit increases cash
  });

  it("404s for an unknown entry id", async () => {
    const res = await inject(app, db.companyA, "/api/c/entries/999999999");
    expect(res.statusCode).toBe(404);
  });

  it("404s for a cross-company entry id (RLS makes it look unknown)", async () => {
    const res = await inject(app, db.companyB, `/api/c/entries/${entry1Id}`);
    expect(res.statusCode).toBe(404);
  });

  it("400s for a non-numeric entry id", async () => {
    const res = await inject(app, db.companyA, "/api/c/entries/not-a-number");
    expect(res.statusCode).toBe(400);
  });

  it("400s (not 500) for an entry id too long to be a bigint", async () => {
    const res = await inject(app, db.companyA, "/api/c/entries/9999999999999999999");
    expect(res.statusCode).toBe(400);
    expect(res.json().error).toMatch(/whole number/i);
  });
});

// ---------------------------------------------------------------------------
// health.wrongSign — Task 12. Placed last (own beforeAll) so this fixture's
// entry, which deliberately makes two otherwise-unused accounts carry a
// backwards balance, never perturbs any of the hand-computed totals the
// describe blocks above assert against (they all run to completion first;
// vitest runs describe blocks in file declaration order). "1200"/"2200"
// (Due from/to Affiliate) are seeded by tests/helpers/db.ts's CHART but never
// touched by any other test in this file, specifically so they're free to use
// here without disturbing Cash/AR/Revenue/Expense's numbers above.
// ---------------------------------------------------------------------------
describe("GET /api/c/reports/dashboard — health.wrongSign", () => {
  beforeAll(async () => {
    // One balanced entry, deliberately posted "backwards": debiting a
    // liability (Due to Affiliate) and crediting an asset (Due from
    // Affiliate) — the opposite of each account's normal side. Both legs
    // land in wrongSign.
    const wrongEntryId = await insertDraftEntry(cA, db.companyA, db.userA, {
      date: "2025-06-01", memo: "Wrong-sign fixture",
      lines: [
        { accountId: db.accounts["2200"].A, debit: "50.0000" },
        { accountId: db.accounts["1200"].A, credit: "50.0000" },
      ],
    });
    await postEntry(cA, db.companyA, wrongEntryId);
  });

  it("flags both accounts, presentation-signed negative either way", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    const body = res.json();
    const byName = new Map(
      body.health.wrongSign.map((w: { name: string; balance: string }) => [w.name, w.balance]),
    );
    // Asset gone negative (credit-heavy) — shown as-is, no flip.
    expect(byName.get("Due from Affiliate")).toBe("-50.0000");
    // Liability gone positive (debit-heavy) — flipped for display, also negative.
    expect(byName.get("Due to Affiliate")).toBe("-50.0000");
    expect(body.health.wrongSign).toHaveLength(2);
  });

  it("is empty for company B (RLS, not just 'different' numbers)", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/dashboard");
    expect(res.json().health.wrongSign).toEqual([]);
  });
});

// ---------------------------------------------------------------------------
// health.neverReconciled / health.staleReconciliation — Bank Rec Task 5.
// Placed last, own beforeAll, for the same reason as health.wrongSign above:
// this fixture directly inserts an account_reconciliations row (rather than
// going through the full import/reconcile API, which is exercised end to end
// by tests/server/bankrec.test.ts already) and must run after every earlier
// describe block so it never perturbs their hand-computed numbers.
// ---------------------------------------------------------------------------
describe("GET /api/c/reports/dashboard — health.neverReconciled / staleReconciliation", () => {
  it("flags every active asset account with posted activity that's never been reconciled", async () => {
    const res = await inject(app, db.companyA, "/api/c/reports/dashboard");
    expect(res.statusCode).toBe(200);
    const names = res.json().health.neverReconciled.map((r: { name: string }) => r.name).sort();
    // Cash/AR from the top-of-file fixture, Due from Affiliate from the
    // wrongSign block just above — all three now have posted activity and
    // none has ever been reconciled.
    expect(names).toEqual(["Accounts Receivable", "Cash", "Due from Affiliate"]);
  });

  it("flags a reconciliation 30+ days stale, and drops the account out of neverReconciled once it has one", async () => {
    const staleDate = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10);
    await db.admin.query(
      `INSERT INTO account_reconciliations
         (company_id, money_account_id, statement_id, reconciled_through, statement_balance, created_by)
       VALUES ($1, $2, NULL, $3, NULL, $4)`,
      [db.companyA, db.accounts["1200"]!.A, staleDate, db.userA],
    );

    const res = await inject(app, db.companyA, "/api/c/reports/dashboard");
    const body = res.json();

    expect(body.health.neverReconciled.map((r: { name: string }) => r.name).sort()).toEqual([
      "Accounts Receivable", "Cash",
    ]);
    expect(body.health.staleReconciliation).toEqual([
      { accountId: String(db.accounts["1200"]!.A), name: "Due from Affiliate", reconciledThrough: staleDate },
    ]);
  });

  it("is empty for company B (RLS, not just 'different' numbers)", async () => {
    const res = await inject(app, db.companyB, "/api/c/reports/dashboard");
    const body = res.json();
    expect(body.health.neverReconciled).toEqual([]);
    expect(body.health.staleReconciliation).toEqual([]);
  });
});
