// Chart of Accounts management — Task 1 of
// docs/superpowers/plans/2026-08-02-chart-management.md (spec:
// docs/superpowers/specs/2026-08-02-chart-management-design.md).
//
// Covers, in order:
//   1. Migration 0016's type/subtype CHECK matrix, exercised at the DATABASE
//      level (valid and invalid combos, straight INSERTs as superuser — the
//      constraint must bind regardless of the API).
//   2. Backfill correctness, to the extent a fresh test DB can express it:
//      - the migration's hardcoded (code, name, type, subtype) VALUES list is
//        parsed out of the SQL file and asserted equal to the live template
//        modules, so the two can never drift silently;
//      - template stamping (POST /api/c/setup/fresh) writes subtypes;
//      - enable_sales_tax()'s CREATE path (0016's replaced function) births
//        the payable account tagged other-current-liability.
//      HONEST LIMITATION: the migration's one-time backfill UPDATEs
//      themselves run against an EMPTY accounts table in every test DB
//      (migrations run before any seed), so their effect on pre-existing
//      rows cannot be exercised here — that path was verified once, by hand,
//      against the dev database when 0016 was applied (template-spine rows
//      and the settings-designated AR/AP/sales-tax accounts came out tagged,
//      everything else NULL).
//   3. The accounts API: GET (subtype/active fields, includeInactive,
//      withBalances tied to trial_balance, grouped legacy shape), POST
//      (auto-suggested codes per subtype range, duplicate name/code English),
//      PATCH (rename, recode, subtype set/untag, subtype-mismatch English,
//      deactivate/reactivate, the settings-designated deactivation guard),
//      and RLS.
//   4. The documented DECISION: deactivation hides an account from pickers
//      only — a draft invoice referencing a now-inactive account still
//      issues.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
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";
import { CHART_TEMPLATES, SUBTYPES_BY_TYPE } from "../../server/src/templates/types.js";
import { suggestCode, SUBTYPE_CODE_RANGES } from "../../server/src/accounts.js";

let db: TestDb;
let app: FastifyInstance;

beforeAll(async () => {
  db = await createTestDb();
  app = await buildApp({
    databaseUrl: await dbUrl(db.dbName, "app", "app"),
    adminDatabaseUrl: await dbUrl(db.dbName),
  });
}, 60_000);

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

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

async function createCompany(name: string): Promise<string> {
  const res = await app.inject({ method: "POST", url: "/api/companies", payload: { name } });
  expect(res.statusCode).toBe(201);
  return (res.json() as { id: string }).id;
}

// ---------------------------------------------------------------------------
// 1. the CHECK matrix, at the database level
// ---------------------------------------------------------------------------

describe("accounts_subtype_valid_for_type CHECK matrix (DB level)", () => {
  const VALID: Array<[type: string, subtype: string]> = [];
  for (const [type, subtypes] of Object.entries(SUBTYPES_BY_TYPE)) {
    for (const subtype of subtypes) VALID.push([type, subtype]);
  }
  // A representative wrong type for every subtype (each subtype belongs to
  // exactly one type, so any other type must refuse it).
  const WRONG_TYPE: Record<string, string> = {
    asset: "liability", liability: "asset", equity: "income",
    income: "expense", expense: "income",
  };

  let nextCode = 9000;

  it("accepts every valid (type, subtype) pair and NULL", async () => {
    expect(VALID).toHaveLength(16);
    for (const [type, subtype] of VALID) {
      const code = String(nextCode++);
      await db.admin.query(
        `INSERT INTO accounts (company_id, code, name, type, subtype)
         VALUES ($1, $2, $3, $4, $5)`,
        [db.companyA, code, `Matrix Valid ${code}`, type, subtype],
      );
    }
    const nullCode = String(nextCode++);
    await db.admin.query(
      `INSERT INTO accounts (company_id, code, name, type, subtype)
       VALUES ($1, $2, $3, 'asset', NULL)`,
      [db.companyA, nullCode, `Matrix Valid ${nullCode}`],
    );
    // cleanup so later chart-wide assertions aren't polluted
    await db.admin.query(
      "DELETE FROM accounts WHERE company_id = $1 AND name LIKE 'Matrix Valid %'",
      [db.companyA],
    );
  });

  it("rejects every subtype under a wrong type, naming the constraint", async () => {
    for (const [type, subtype] of VALID) {
      const wrongType = WRONG_TYPE[type]!;
      const code = String(nextCode++);
      await expect(
        db.admin.query(
          `INSERT INTO accounts (company_id, code, name, type, subtype)
           VALUES ($1, $2, $3, $4, $5)`,
          [db.companyA, code, `Matrix Invalid ${code}`, wrongType, subtype],
        ),
      ).rejects.toThrow(/accounts_subtype_valid_for_type/);
    }
  });

  it("rejects a made-up subtype under every type", async () => {
    const code = String(nextCode++);
    await expect(
      db.admin.query(
        `INSERT INTO accounts (company_id, code, name, type, subtype)
         VALUES ($1, $2, $3, 'asset', 'piggy-bank')`,
        [db.companyA, code, `Matrix Invalid ${code}`],
      ),
    ).rejects.toThrow(/accounts_subtype_valid_for_type/);
  });

  it("rejects an UPDATE that breaks the matrix too", async () => {
    await expect(
      db.admin.query(
        "UPDATE accounts SET subtype = 'bank' WHERE company_id = $1 AND id = $2",
        [db.companyA, db.accounts["2000"]!.A], // liability
      ),
    ).rejects.toThrow(/accounts_subtype_valid_for_type/);
  });
});

// ---------------------------------------------------------------------------
// 2. backfill correctness
// ---------------------------------------------------------------------------

describe("migration 0016 backfill", () => {
  it("hardcodes exactly the live template rows (no drift possible)", async () => {
    const testDir = path.dirname(fileURLToPath(import.meta.url));
    const migrationPath = path.resolve(
      testDir, "../../db/migrations/0016_account_subtypes.sql",
    );
    const sql = await readFile(migrationPath, "utf8");

    // The template-row VALUES list sits between "FROM (VALUES" and the
    // closing ") AS v" of backfill (a).
    const start = sql.indexOf("FROM (VALUES");
    const end = sql.indexOf(") AS v (code, name, type, subtype)");
    expect(start).toBeGreaterThan(-1);
    expect(end).toBeGreaterThan(start);
    const block = sql.slice(start, end);

    const tupleRe = /\('((?:[^']|'')*)',\s*'((?:[^']|'')*)',\s*'((?:[^']|'')*)',\s*'((?:[^']|'')*)'\)/g;
    const migrationRows = new Map<string, string>();
    for (const m of block.matchAll(tupleRe)) {
      const [, code, name, type, subtype] = m.map((s) => s?.replaceAll("''", "'"));
      migrationRows.set(`${code}|${name}|${type}`, subtype!);
    }

    const templateRows = new Map<string, string>();
    for (const template of CHART_TEMPLATES) {
      for (const a of template.accounts) {
        const key = `${a.code}|${a.name}|${a.type}`;
        const existing = templateRows.get(key);
        // Two templates sharing an identical (code, name, type) row must
        // agree on subtype, or the migration list would be ambiguous.
        if (existing !== undefined) expect(existing).toBe(a.subtype);
        templateRows.set(key, a.subtype);
      }
    }

    expect(migrationRows.size).toBeGreaterThan(0);
    // Exact equality in both directions.
    expect(Object.fromEntries([...migrationRows.entries()].sort()))
      .toEqual(Object.fromEntries([...templateRows.entries()].sort()));
  });

  it("template stamping writes a subtype on every account", async () => {
    const companyId = await createCompany("Subtype Stamp Restaurant Co");
    const res = await post(companyId, "/api/c/setup/fresh", { templateKey: "restaurant" });
    expect(res.statusCode).toBe(200);

    const rows = await db.admin.query<{ code: string; subtype: string | null; type: string }>(
      "SELECT code, type, subtype FROM accounts WHERE company_id = $1 ORDER BY code",
      [companyId],
    );
    expect(rows.rows.length).toBeGreaterThan(0);
    for (const r of rows.rows) {
      expect(r.subtype, `code ${r.code} should be tagged`).not.toBeNull();
      expect(SUBTYPES_BY_TYPE[r.type as keyof typeof SUBTYPES_BY_TYPE])
        .toContain(r.subtype);
    }
    const byCode = new Map(rows.rows.map((r) => [r.code, r.subtype]));
    expect(byCode.get("1010")).toBe("bank");                    // Checking
    expect(byCode.get("1100")).toBe("accounts-receivable");     // A/R
    expect(byCode.get("2000")).toBe("accounts-payable");        // A/P
    expect(byCode.get("2100")).toBe("credit-card");             // Credit Card
    expect(byCode.get("2200")).toBe("other-current-liability"); // Sales Tax Payable
    expect(byCode.get("2400")).toBe("long-term-liability");     // Loan Payable
    expect(byCode.get("3000")).toBe("equity");
    expect(byCode.get("4020")).toBe("other-income");
    expect(byCode.get("5000")).toBe("cost-of-goods-sold");      // Food Purchases
    expect(byCode.get("6000")).toBe("expense");
  });

  it("enable_sales_tax's CREATE path births the payable tagged other-current-liability", async () => {
    // The raw seeded chart (tests/helpers/db.ts) has no 'Sales Tax Payable',
    // so enabling creates one — 0016's replaced function stamps its subtype.
    const res = await put(db.companyB, "/api/c/settings/sales-tax", { enabled: true });
    expect(res.statusCode).toBe(200);
    const created = await db.admin.query<{ subtype: string | null }>(
      `SELECT a.subtype FROM company_settings s
         JOIN accounts a ON a.company_id = s.company_id AND a.id = s.sales_tax_account_id
        WHERE s.company_id = $1`,
      [db.companyB],
    );
    expect(created.rows[0]!.subtype).toBe("other-current-liability");
  });
});

// ---------------------------------------------------------------------------
// 3a. GET /api/c/accounts — subtype/active fields, includeInactive, balances
// ---------------------------------------------------------------------------

describe("GET /api/c/accounts (chart management additions)", () => {
  it("returns subtype (null on untagged rows) and active on every row", async () => {
    const res = await get(db.companyA, "/api/c/accounts");
    expect(res.statusCode).toBe(200);
    const body = res.json() as Array<Record<string, unknown>>;
    for (const row of body) {
      expect(Object.keys(row).sort()).toEqual(
        ["active", "code", "id", "name", "subtype", "type"]);
      expect(row.active).toBe(true);
    }
  });

  it("hides inactive accounts by default; includeInactive=1 shows them", async () => {
    const created = await post(db.companyA, "/api/c/accounts", {
      name: "Closed Card", type: "liability", subtype: "credit-card",
    });
    expect(created.statusCode).toBe(201);
    const id = (created.json() as { id: string }).id;
    const off = await patch(db.companyA, `/api/c/accounts/${id}`, { active: false });
    expect(off.statusCode).toBe(200);

    const plain = (await get(db.companyA, "/api/c/accounts")).json() as Array<{ id: string }>;
    expect(plain.some((a) => a.id === id)).toBe(false);

    const all = (await get(db.companyA, "/api/c/accounts?includeInactive=1")).json() as
      Array<{ id: string; active: boolean }>;
    const found = all.find((a) => a.id === id);
    expect(found).toBeDefined();
    expect(found!.active).toBe(false);
  });

  it("withBalances=1 returns natural-signed balances that tie to trial_balance()", async () => {
    // 500 in: debit Cash (1000), credit Revenue (4000).
    const entry = await post(db.companyA, "/api/c/entries", {
      kind: "money-in", date: "2024-05-01", amount: "500",
      intoAccountId: String(db.accounts["1000"]!.A),
      categoryAccountId: String(db.accounts["4000"]!.A),
    });
    expect(entry.statusCode).toBe(201);

    const res = await get(db.companyA, "/api/c/accounts?withBalances=1");
    expect(res.statusCode).toBe(200);
    const body = res.json() as Array<{ code: string; type: string; balance: string }>;
    const byCode = new Map(body.map((r) => [r.code, r]));

    // Natural-signed: debit-normal Cash reads positive as stored; credit-
    // normal Revenue's raw balance is negative (credit) and reads positive.
    expect(byCode.get("1000")!.balance).toBe("500.0000");
    expect(byCode.get("4000")!.balance).toBe("500.0000");
    expect(byCode.get("6000")!.balance).toBe("0.0000");

    // Tie every row to trial_balance() directly (raw, debit-positive).
    const tb = await db.admin.query<{ code: string; type: string; balance: string }>(
      "SELECT code, type, balance FROM trial_balance($1, NULL)", [db.companyA],
    );
    for (const row of tb.rows) {
      const apiRow = byCode.get(row.code);
      if (!apiRow) continue; // inactive rows are hidden without includeInactive
      const natural = row.type === "asset" || row.type === "expense"
        ? row.balance
        : row.balance.startsWith("-") ? row.balance.slice(1)
          : /^0+\.0+$/.test(row.balance) ? row.balance : `-${row.balance}`;
      expect(apiRow.balance).toBe(natural);
    }
  });

  it("keeps the legacy grouped=1 shape", async () => {
    const res = await get(db.companyA, "/api/c/accounts?grouped=1");
    expect(res.statusCode).toBe(200);
    const body = res.json() as Record<string, unknown>;
    expect(Object.keys(body).sort()).toEqual(["income", "money", "other", "spending"]);
  });
});

// ---------------------------------------------------------------------------
// 3b. POST /api/c/accounts
// ---------------------------------------------------------------------------

describe("POST /api/c/accounts", () => {
  it("creates an account with an explicit code", async () => {
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Petty Cash Box", type: "asset", subtype: "bank", code: "1050",
    });
    expect(res.statusCode).toBe(201);
    expect(res.json()).toEqual({
      id: expect.any(String), code: "1050", name: "Petty Cash Box",
      type: "asset", subtype: "bank", active: true,
    });
  });

  it("auto-suggests the next free code in the subtype's conventional range", async () => {
    // Company A's seeded codes: 1000, 1100, 1200, 2000, 2200, 3000, 4000,
    // 6000 (+ 1050 and the credit-card 2100 created above). A new bank
    // account walks 1000 (taken) -> 1010.
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Money Market", type: "asset", subtype: "bank",
    });
    expect(res.statusCode).toBe(201);
    expect((res.json() as { code: string }).code).toBe("1010");

    // credit-card base 2100 was taken by "Closed Card" above -> 2110.
    const cc = await post(db.companyA, "/api/c/accounts", {
      name: "Chase Ink Test", type: "liability", subtype: "credit-card",
    });
    expect(cc.statusCode).toBe(201);
    expect((cc.json() as { code: string }).code).toBe("2110");

    // cost-of-goods-sold: nothing in 5xxx yet -> 5000.
    const cogs = await post(db.companyA, "/api/c/accounts", {
      name: "Materials Cost", type: "expense", subtype: "cost-of-goods-sold",
    });
    expect(cogs.statusCode).toBe(201);
    expect((cogs.json() as { code: string }).code).toBe("5000");
  });

  it("falls back to the bare type's range when no subtype is given", async () => {
    // expense range base 6000 (taken) -> 6010; account stays untagged.
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Sundry Costs", type: "expense",
    });
    expect(res.statusCode).toBe(201);
    const body = res.json() as { code: string; subtype: string | null };
    expect(body.code).toBe("6010");
    expect(body.subtype).toBeNull();
  });

  it("refuses a subtype that doesn't fit the type, in English", async () => {
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Confused Account", type: "asset", subtype: "credit-card",
    });
    expect(res.statusCode).toBe(422);
    expect((res.json() as { error: string }).error).toBe(
      'The "credit-card" grouping doesn\'t fit an asset account — pick one of: bank, accounts-receivable, other-current-asset, fixed-asset, other-asset.',
    );
  });

  it("refuses a duplicate name in English", async () => {
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Petty Cash Box", type: "asset",
    });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toBe(
      "You already have an account with that name.",
    );
  });

  it("points at Show inactive when the duplicate name is deactivated", async () => {
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Closed Card", type: "liability",
    });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toContain("it's deactivated");
  });

  it("refuses a duplicate code in English (schema UNIQUE, mapped)", async () => {
    const res = await post(db.companyA, "/api/c/accounts", {
      name: "Code Collision Co", type: "asset", code: "1000",
    });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toBe(
      "You already have an account using that code — pick a different one.",
    );
  });

  it("validates the body in English", async () => {
    const res = await post(db.companyA, "/api/c/accounts", { name: "", type: "asset" });
    expect(res.statusCode).toBe(400);
    expect((res.json() as { error: string }).error).toContain("An account needs a name.");

    const badType = await post(db.companyA, "/api/c/accounts", { name: "X", type: "banana" });
    expect(badType.statusCode).toBe(400);
    expect((badType.json() as { error: string }).error).toContain("five account types");
  });
});

describe("suggestCode (pure)", () => {
  it("walks in steps of 10 first, then fills gaps, then gives up", () => {
    expect(suggestCode(new Set(), [2100, 2199])).toBe("2100");
    expect(suggestCode(new Set(["2100"]), [2100, 2199])).toBe("2110");
    // every step-10 slot taken -> first step-1 gap
    const tens = new Set(
      Array.from({ length: 10 }, (_, i) => String(2100 + i * 10)));
    expect(suggestCode(tens, [2100, 2199])).toBe("2101");
    // full range taken -> null
    const all = new Set(
      Array.from({ length: 100 }, (_, i) => String(2100 + i)));
    expect(suggestCode(all, [2100, 2199])).toBeNull();
  });

  it("has a range for all sixteen subtypes, inside the right type bucket", () => {
    expect(Object.keys(SUBTYPE_CODE_RANGES)).toHaveLength(16);
    const bucket: Record<string, [number, number]> = {
      asset: [1000, 1999], liability: [2000, 2999], equity: [3000, 3999],
      income: [4000, 4999], expense: [5000, 8999],
    };
    for (const [type, subtypes] of Object.entries(SUBTYPES_BY_TYPE)) {
      for (const subtype of subtypes) {
        const [lo, hi] = SUBTYPE_CODE_RANGES[subtype]!;
        const [blo, bhi] = bucket[type]!;
        expect(lo, `${subtype} low`).toBeGreaterThanOrEqual(blo);
        expect(hi, `${subtype} high`).toBeLessThanOrEqual(bhi);
        expect(lo).toBeLessThanOrEqual(hi);
      }
    }
  });
});

// ---------------------------------------------------------------------------
// 3c. PATCH /api/c/accounts/:id
// ---------------------------------------------------------------------------

describe("PATCH /api/c/accounts/:id", () => {
  it("renames, recodes, and retags in one partial update", async () => {
    const created = await post(db.companyA, "/api/c/accounts", {
      name: "Editable Account", type: "liability",
    });
    const id = (created.json() as { id: string }).id;

    const res = await patch(db.companyA, `/api/c/accounts/${id}`, {
      name: "Edited Account", code: "2905", subtype: "line-of-credit",
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({
      id, code: "2905", name: "Edited Account", type: "liability",
      subtype: "line-of-credit", active: true,
    });

    // untag with explicit null
    const untag = await patch(db.companyA, `/api/c/accounts/${id}`, { subtype: null });
    expect(untag.statusCode).toBe(200);
    expect((untag.json() as { subtype: string | null }).subtype).toBeNull();
  });

  it("refuses a subtype that doesn't fit the account's type, in English", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["4000"]!.A}`, { subtype: "bank" });
    expect(res.statusCode).toBe(422);
    expect((res.json() as { error: string }).error).toContain(
      "doesn't fit an income account");
  });

  it("refuses a duplicate name and a duplicate code in English", async () => {
    const name = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["6000"]!.A}`, { name: "Cash" });
    expect(name.statusCode).toBe(409);
    expect((name.json() as { error: string }).error).toBe(
      "You already have an account with that name.");

    const code = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["6000"]!.A}`, { code: "1000" });
    expect(code.statusCode).toBe(409);
    expect((code.json() as { error: string }).error).toBe(
      "You already have an account using that code — pick a different one.");
  });

  it("deactivates and reactivates a normal account", async () => {
    const created = await post(db.companyA, "/api/c/accounts", {
      name: "Toggle Account", type: "expense",
    });
    const id = (created.json() as { id: string }).id;

    const off = await patch(db.companyA, `/api/c/accounts/${id}`, { active: false });
    expect(off.statusCode).toBe(200);
    expect((off.json() as { active: boolean }).active).toBe(false);

    const on = await patch(db.companyA, `/api/c/accounts/${id}`, { active: true });
    expect(on.statusCode).toBe(200);
    expect((on.json() as { active: boolean }).active).toBe(true);
  });

  it("refuses a type change explicitly instead of silently ignoring it", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["6000"]!.A}`, { type: "income" });
    expect(res.statusCode).toBe(400);
    expect((res.json() as { error: string }).error).toBe(
      "An account's type can't be changed — deactivate this account and create a new one instead.",
    );
    // Even alongside otherwise-valid fields, and nothing else is applied.
    const mixed = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["6000"]!.A}`,
      { type: "income", name: "Should Not Stick" });
    expect(mixed.statusCode).toBe(400);
    const row = await db.admin.query<{ name: string; type: string }>(
      "SELECT name, type FROM accounts WHERE id = $1", [db.accounts["6000"]!.A]);
    expect(row.rows[0]).toEqual({ name: "Operating Expense", type: "expense" });
  });

  it("404s for an unknown or cross-company id (RLS)", async () => {
    const missing = await patch(db.companyA, "/api/c/accounts/999999999", { name: "X" });
    expect(missing.statusCode).toBe(404);

    // Company B must not be able to see OR touch company A's account.
    const cross = await patch(
      db.companyB, `/api/c/accounts/${db.accounts["6000"]!.A}`, { name: "Stolen" });
    expect(cross.statusCode).toBe(404);
    const untouched = await db.admin.query<{ name: string }>(
      "SELECT name FROM accounts WHERE id = $1", [db.accounts["6000"]!.A]);
    expect(untouched.rows[0]!.name).toBe("Operating Expense");
  });
});

// ---------------------------------------------------------------------------
// 3d. the deactivation guard for settings-designated accounts
// ---------------------------------------------------------------------------

describe("deactivation guard (company_settings-designated accounts)", () => {
  beforeAll(async () => {
    // Designate company A's AR (1100, asset), AP (2000, liability), and
    // sales tax (2200 'Due to Affiliate', liability) accounts.
    await db.admin.query(
      `INSERT INTO company_settings (company_id, ar_account_id) VALUES ($1, $2)
       ON CONFLICT (company_id) DO UPDATE SET ar_account_id = $2`,
      [db.companyA, db.accounts["1100"]!.A],
    );
    await db.admin.query(
      "UPDATE company_settings SET ap_account_id = $2, sales_tax_account_id = $3 WHERE company_id = $1",
      [db.companyA, db.accounts["2000"]!.A, db.accounts["2200"]!.A],
    );
  });

  it("refuses to deactivate the designated AR account, naming the setting", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["1100"]!.A}`, { active: false });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toBe(
      "This is the Accounts Receivable account your invoices post to (set in Settings) — change that setting before deactivating this account.",
    );
    const still = await db.admin.query<{ active: boolean }>(
      "SELECT active FROM accounts WHERE id = $1", [db.accounts["1100"]!.A]);
    expect(still.rows[0]!.active).toBe(true);
  });

  it("refuses to deactivate the designated AP account, naming the setting", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["2000"]!.A}`, { active: false });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toBe(
      "This is the Accounts Payable account your bills post to (set in Settings) — change that setting before deactivating this account.",
    );
  });

  it("refuses to deactivate the designated sales tax account, naming the setting", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["2200"]!.A}`, { active: false });
    expect(res.statusCode).toBe(409);
    expect((res.json() as { error: string }).error).toBe(
      "This is the Sales Tax Payable account (set in Settings) — change that setting before deactivating this account.",
    );
  });

  it("still allows renaming/retagging a designated account (only deactivation guards)", async () => {
    const res = await patch(
      db.companyA, `/api/c/accounts/${db.accounts["1100"]!.A}`,
      { subtype: "accounts-receivable" });
    expect(res.statusCode).toBe(200);
    expect((res.json() as { subtype: string }).subtype).toBe("accounts-receivable");
  });
});

// ---------------------------------------------------------------------------
// 4. DECISION: deactivation hides from pickers only — drafts still issue
// ---------------------------------------------------------------------------

describe("drafts referencing a now-inactive account still issue", () => {
  it("issues an invoice whose line category was deactivated after drafting", async () => {
    // Own income account so nothing else in this file is disturbed.
    const acct = await post(db.companyA, "/api/c/accounts", {
      name: "Soon Closed Income", type: "income", subtype: "income",
    });
    expect(acct.statusCode).toBe(201);
    const accountId = (acct.json() as { id: string }).id;

    const customer = await post(db.companyA, "/api/c/customers", { name: "Draft Decision Customer" });
    expect(customer.statusCode).toBe(201);
    const invoice = await post(db.companyA, "/api/c/invoices", {
      customerId: (customer.json() as { id: string }).id,
      invoiceDate: "2024-06-01",
      dueDate: "2024-07-01",
      lines: [{ unitPrice: "125.00", incomeAccountId: accountId }],
    });
    expect(invoice.statusCode).toBe(201);
    const invoiceId = (invoice.json() as { invoiceId: string }).invoiceId;

    // Deactivate the category AFTER the draft exists.
    const off = await patch(db.companyA, `/api/c/accounts/${accountId}`, { active: false });
    expect(off.statusCode).toBe(200);

    // Pickers no longer offer it...
    const pickers = (await get(db.companyA, "/api/c/accounts")).json() as Array<{ id: string }>;
    expect(pickers.some((a) => a.id === accountId)).toBe(false);

    // ...but the draft still issues — history is history.
    const issue = await post(db.companyA, `/api/c/invoices/${invoiceId}/issue`);
    expect(issue.statusCode).toBe(200);
  });
});
