// Bills API — Task 2 of docs/superpowers/plans/2026-08-01-ap.md. Same
// route-module pattern as customers.ts/invoices.ts: every route lives under
// /api/c/ (company scope from X-Company-Id, enforced by companyContextPlugin)
// and every query/insert runs inside db.withCompany() so RLS is the real
// guard (CLAUDE.md §4/§12), never just the WHERE clauses written here.
// created_by is always the company's owner user (see resolveOwner below) —
// same pattern as entries.ts/invoices.ts/payments.ts: there's no login in v1.
//
// Documents sit ON TOP of the ledger (docs/superpowers/specs/2026-08-01-ap-design.md,
// mirroring the AR design): a draft bill has no accounting impact at all.
// issue_bill() (a SECURITY DEFINER function from db/migrations/0009_ap.sql) is
// what actually posts (or, for document_only bills carrying already-migrated
// history, just stamps issued_on with no posting). Unlike invoices, a bill
// carries the VENDOR's own reference (ref_no, optional) rather than a
// per-company assigned number — there is no bill counter. Every derived
// amount here (total, applied, open, state) is computed in SQL at query time
// from bill_lines / bill_payment_applications — CLAUDE.md §3.4, never
// stored, never cached.
//
// ---------------------------------------------------------------------------
// Rounding rule (binding — mirrors invoices.ts's identical note):
// bill_lines.amount = quantity * unit_price is computed SERVER-SIDE using
// integer arithmetic on the string forms of quantity/unit_price (never a JS
// float, per CLAUDE.md §2), and rounded HALF-UP to the nearest cent. Example:
// quantity 0.5 * unit_price 99.99 = 49.995 exactly, which has no exact cent
// representation — this rounds UP to 50.00 (round-half-up, not banker's
// rounding). See computeLineAmount()/toMicros()/fromMicros() below for the
// actual integer-only implementation.
//
// There is NO exception to this for item lines. An earlier draft of the
// inventory work rounded stocked lines to four decimals so the movement's
// recomputed value would match; that produced an UNPAYABLE BILL (0.5 x $99.99
// = 49.9950, the payment API accepts whole cents, and half a cent sat open in
// A/P aging forever). Money is whole cents everywhere. The movement now takes
// the line's cent-rounded amount as its VALUE and derives the per-unit cost
// from it — see db/migrations/0020_inventory_receiving_fixes.sql.
// ---------------------------------------------------------------------------
//
// ---------------------------------------------------------------------------
// Item lines (docs/superpowers/specs/2026-08-03-inventory-design.md).
//
// A bill line may name an item. Receiving stock IS entering the bill — there
// is no separate receipt document and no second journal entry (spec as-built
// decision 1), so the item, the quantity and the cost all live on the bill
// line, and bill_lines.quantity IS the received quantity (decision 2).
//
//   - a STOCKED item (kind='inventory') posts to that item's own inventory
//     account, and issue_bill() (db/migrations/0019_inventory_receiving.sql)
//     records the receipt movement on the same entry, in the same transaction.
//     Its categoryAccountId is not the caller's to choose: it is the item's
//     inventory account, because 0018's deferred per-account check requires the
//     money and the stock to land on the same account.
//   - a NON-STOCKED item (non_inventory / service) behaves exactly like today's
//     category line: it posts to the item's own cost account, or to an
//     explicitly supplied categoryAccountId if the caller sends one.
//   - a line with NO item is completely unchanged.
//
// Three refusals live here (all also enforced in the database, per §12):
//   - a document_only bill with a stocked line. It posts NOTHING, and 0018
//     forbids a movement without a posted entry, so "received but never
//     posted" has to be impossible. issue_bill() is the real guard; this is
//     the same refusal said at DRAFT time, matching invoices.ts. (Before
//     review it was only said at issue, which — since the UI saves and issues
//     a document-only bill in one click — left an orphan draft behind every
//     attempt.)
//   - a stocked-item line with no quantity. bill_lines.quantity is NOT NULL
//     DEFAULT 1, so the database cannot tell "omitted" from "one" — only the
//     API can, and silently receiving one unit of something is worse than
//     asking.
//   - a plain category line coded straight to an inventory account. That would
//     move money into Inventory with no quantity behind it, and 0018's deferred
//     check cannot see it (it fires on movements, and this entry has none), so
//     0019 adds bill_lines_item_consistent() as the real guard and this is its
//     friendly restatement.
// ---------------------------------------------------------------------------

import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { Db } from "./db.js";
import type { PoolClient } from "pg";
import { mapPgError } from "./errors.js";
import { findOwnerId } from "../../tools/lib/journal-load.js";
import { blankToUndefined } from "./lib/textNormalize.js";

// ---------------------------------------------------------------------------
// shared validation + helpers
// ---------------------------------------------------------------------------

// Same bound as customers.ts/invoices.ts/reports.ts: every id here is a
// Postgres bigint (GENERATED ALWAYS AS IDENTITY, max 19 digits) — capping at
// 18 digits turns a too-long id into a friendly 400 instead of a raw
// Postgres "bigint out of range" 500.
const idSchema = z.string().regex(/^\d{1,18}$/, "must be a whole number id (no more than 18 digits)");
const idParamSchema = z.object({ id: idSchema });

const isoDateSchema = z.iso.date({ error: "must be a date like 2024-01-15 (YYYY-MM-DD)" });
const optionalIsoDate = z.preprocess(
  (v) => (v === "" || v === undefined ? undefined : v),
  isoDateSchema.optional(),
);

const memoSchema = z.preprocess(blankToUndefined, z.string().max(1000).optional());
const descriptionSchema = z.preprocess(blankToUndefined, z.string().max(500).optional());
// The vendor's own reference/invoice number — free text, since vendors number
// their own paper however they like (unlike invoice_no, there is no internal
// counter here — see db/migrations/0009_ap.sql's header comment).
const refNoSchema = z.preprocess(
  blankToUndefined,
  z.string().max(200, "That reference is too long.").optional(),
);

// Quantity: numeric(12,4) in storage — up to 4 decimal places (0.5 hours,
// 2.25 units, etc.) and, since the column only has 12 total digits of
// precision (8 of them before the decimal point once 4 are reserved for the
// fraction), the integer part is capped at 8 digits so a wildly oversized
// quantity is a friendly 400 here rather than a raw Postgres "numeric field
// overflow" 500 at insert time. Deliberately a regex, never Number()/
// parseFloat(), per CLAUDE.md §2. Defaults to "1" (see bill_lines' own
// DEFAULT 1) when omitted from a line.
const quantitySchema = z
  .string()
  .regex(
    /^(0|[1-9]\d{0,7})(\.\d{1,4})?$/,
    "Enter a quantity like 1 or 0.5 (up to 4 decimal places, no leading zeros, no more than 8 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a quantity greater than zero." })
  .optional();

// Unit price: dollars-and-cents input (2 decimal places), matching how money
// is entered everywhere else in this app (entries.ts's amountSchema) — even
// though bill_lines.unit_price is numeric(19,4) in storage, the API only
// accepts 2-decimal prices; the extra precision exists for the computed
// `amount` (quantity * unit_price), not for hand-typed unit prices. The
// integer part is capped at 13 digits (comfortably under numeric(19,4)'s own
// 15-digit integer-part limit) so an absurd unit price is a friendly 400
// here, not a raw Postgres overflow later. Even within this per-field bound,
// quantity * unitPrice can still overflow the STORED amount's limit (8 +
// 13 = 21 possible integer digits vs. numeric(19,4)'s 15) — that's what
// computeLineAmount()'s own bound check (below) exists to catch.
const unitPriceSchema = z
  .string()
  .regex(
    /^(0|[1-9]\d{0,12})(\.\d{1,2})?$/,
    "Enter a price like 120 or 120.50 (no more than 2 decimal places, no leading zeros, no more than 13 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a price greater than zero." });

// A price with up to FOUR decimal places, accepted only on a line that names
// an item. Sub-cent unit costs are real on the purchasing side (a washer costs
// $0.0042 — the reason 0018 gave inventory_movements.unit_cost six decimals),
// and bill_lines.unit_price is numeric(19,4) so it holds them. A line with NO
// item keeps the two-decimal rule and its exact original message, unchanged.
const itemUnitPriceSchema = z
  .string()
  .regex(
    /^(0|[1-9]\d{0,12})(\.\d{1,4})?$/,
    "Enter a price like 120 or 120.50 (no more than 4 decimal places, no leading zeros, no more than 13 digits before the decimal point).",
  )
  .refine((v) => /[1-9]/.test(v), { message: "Enter a price greater than zero." });

const lineItemSchema = z.object({
  description: descriptionSchema,
  quantity: quantitySchema,
  // Validated by the superRefine below, which picks the 2-decimal rule for a
  // plain category line (unchanged) and the 4-decimal one for an item line.
  unitPrice: z.string(),
  // Optional when the line names an item that carries its own account.
  categoryAccountId: idSchema.optional(),
  itemId: idSchema.optional(),
  poLineId: idSchema.optional(),
}).superRefine((line, ctx) => {
  const schema = line.itemId === undefined ? unitPriceSchema : itemUnitPriceSchema;
  const parsed = schema.safeParse(line.unitPrice);
  if (!parsed.success) {
    for (const issue of parsed.error.issues) {
      ctx.addIssue({ code: "custom", path: ["unitPrice"], message: issue.message });
    }
  }
  if (line.itemId === undefined && line.categoryAccountId === undefined) {
    ctx.addIssue({ code: "custom", path: ["categoryAccountId"], message: "Pick a category for each line." });
  }
  // bill_lines_po_line_requires_item (0018): a PO line can only be fulfilled by
  // a line that says what it is fulfilling it WITH.
  if (line.poLineId !== undefined && line.itemId === undefined) {
    ctx.addIssue({
      code: "custom", path: ["itemId"],
      message: "Pick the item you're receiving before linking this line to a purchase order.",
    });
  }
});

const billCreateSchema = z.object({
  vendorId: idSchema,
  refNo: refNoSchema,
  billDate: isoDateSchema,
  dueDate: optionalIsoDate,
  memo: memoSchema,
  documentOnly: z.boolean().optional(),
  lines: z.array(lineItemSchema).min(1, "Add at least one line."),
});

// PATCH replaces the whole header + line set (draft only — see the plan).
// document_only is intentionally NOT editable here: db/migrations/0009_ap.sql
// only grants app UPDATE on (vendor_id, ref_no, bill_date, due_date, memo),
// so the flag is fixed for the life of the bill, set only at creation.
const billUpdateSchema = z.object({
  vendorId: idSchema,
  refNo: refNoSchema,
  billDate: isoDateSchema,
  dueDate: optionalIsoDate,
  memo: memoSchema,
  lines: z.array(lineItemSchema).min(1, "Add at least one line."),
});

const voidBodySchema = z.object({ date: optionalIsoDate });

const listStatusSchema = z.enum(["draft", "open", "partial", "paid", "void"]);
const listQuerySchema = z.object({
  status: listStatusSchema.optional(),
  vendorId: idSchema.optional(),
  limit: z.coerce.number().int().min(1).max(500).optional(),
  offset: z.coerce.number().int().min(0).optional(),
});

function zodMessage(issues: Array<{ message: string }>): string {
  return issues.map((i) => i.message).join("; ");
}

const NOT_FOUND_BILL = { error: "We couldn't find that bill." };
const NOT_FOUND_VENDOR_FOR_BILL = { status: 422, error: "Pick a vendor for this bill." };
const NOT_CATEGORY_ACCOUNT = {
  status: 422,
  error: "Pick a category account for each line — not the Accounts Payable account itself.",
};
const LINE_AMOUNT_TOO_LARGE = { status: 422, error: "That line amount is too large." };
const NOT_FOUND_ITEM_ON_LINE = { status: 422, error: "One of these lines names an item we couldn't find." };
const NOT_FOUND_PO_LINE_ON_LINE = {
  status: 422,
  error: "One of these lines points at a purchase order line we couldn't find.",
};

const NO_OWNER_ERROR = {
  status: 500,
  error: "This company doesn't have an owner user on file, so bills can't be attributed. This shouldn't happen.",
};

interface PgErrorLike { code?: string; constraint?: string }

/**
 * Duplicate (vendor, refNo): a friendly 409 naming the ACTUAL refNo the
 * caller submitted. This can't be done from errors.ts's mapPgError alone —
 * Postgres suppresses the DETAIL field (where the offending key values would
 * otherwise appear) for a non-owner, RLS-subject role like `app`, even
 * though the row is this company's own and fully visible to it under RLS
 * (verified empirically: the identical INSERT run as the table owner DOES
 * carry a DETAIL; run as `app` it does not). errors.ts's own mapping for
 * bills_vendor_ref_no_unique is kept as a generic (no ref_no) fallback for
 * any other caller that hits this constraint without a submitted refNo on
 * hand (CLAUDE.md §12 — the DB's constraint is still the one source of
 * truth; this is just a friendlier restatement of it). */
function mapDuplicateRefNo(err: unknown, refNo: string | null): { status: number; error: string } | null {
  const e = (err ?? {}) as PgErrorLike;
  if (e.code === "23505" && e.constraint === "bills_vendor_ref_no_unique") {
    return {
      status: 409,
      error: refNo
        ? `You've already entered a bill with reference ${refNo} for this vendor.`
        : "You've already entered a bill with that reference for this vendor.",
    };
  }
  return null;
}

/** Mirrors entries.ts/invoices.ts's resolveOwner — every bill/void is
 *  attributed to the company's owner user (no login in v1). */
async function resolveOwner(
  db: Db, companyId: string, log: { error: (obj: unknown, msg: string) => void },
): Promise<string | null> {
  try {
    return await findOwnerId(db.adminPool, companyId);
  } catch (err) {
    log.error({ err, companyId }, "resolveOwner: findOwnerId failed");
    return null;
  }
}

// ---------------------------------------------------------------------------
// float-free money helpers — see the file header's rounding-rule note, and
// invoices.ts's identical toMicros/fromMicros for the same technique
// elsewhere in this codebase.
// ---------------------------------------------------------------------------

/** Parses a decimal string (no sign expected here — quantity and unit price
 *  are both already known-positive by their zod schemas) into a BigInt count
 *  of ten-thousandths, without ever going through a JS float. */
function toMicros(amount: string): bigint {
  const [wholeRaw, fracRaw = ""] = amount.split(".");
  const frac = (fracRaw + "0000").slice(0, 4);
  return BigInt(wholeRaw || "0") * 10000n + BigInt(frac || "0");
}

/** Inverse of toMicros — always renders 4 decimal places, matching
 *  numeric(19,4) as shown everywhere else in this API. */
function fromMicros(micros: bigint): string {
  const whole = micros / 10000n;
  const frac = (micros % 10000n).toString().padStart(4, "0");
  return `${whole}.${frac}`;
}

// bill_lines.amount is numeric(19,4) — 19 significant digits total, 4 of
// them reserved for the fraction, so the integer part must stay under 10^15.
// Even though unitPrice/quantity are each individually bounded above (13 and
// 8 integer digits respectively), their PRODUCT can still exceed that limit
// (up to 21 integer digits before rounding) — this is the guard for that
// case, expressed in cents (1e2 scale) since computeLineAmount rounds to
// whole cents before this check runs: 10^15 dollars == 10^17 cents.
const MAX_LINE_AMOUNT_CENTS = 10n ** 17n;

/**
 * amount = quantity * unit_price, rounded HALF-UP to the nearest cent (see
 * the file header's rounding-rule note: 0.5 * 99.99 = 49.995 -> 50.00). Both
 * inputs are scaled to ten-thousandths (micros) via toMicros(), so their
 * product is scaled to 1e8; dividing by 1e6 (adding half that divisor first,
 * then integer-dividing — i.e. flooring) rounds to whole cents without ever
 * touching a floating-point number. The result is then rescaled back up to
 * ten-thousandths (cents * 100) and rendered with fromMicros() for storage as
 * numeric(19,4) — or, if the rounded cents value would overflow that
 * column's precision, null (the caller turns this into a friendly 422 rather
 * than letting Postgres raise a raw "numeric field overflow" 500 at insert
 * time).
 */
function computeLineAmount(quantity: string, unitPrice: string): string | null {
  const quantityMicros = toMicros(quantity);
  const priceMicros = toMicros(unitPrice);
  const product = quantityMicros * priceMicros; // scaled 1e8
  const CENTS_DIVISOR = 1_000_000n; // 1e8 / 1e2 (cents)
  const HALF = CENTS_DIVISOR / 2n;
  const cents = (product + HALF) / CENTS_DIVISOR; // round-half-up, both operands non-negative
  if (cents >= MAX_LINE_AMOUNT_CENTS) return null;
  return fromMicros(cents * 100n); // cents (1e2 scale) back up to micros (1e4 scale)
}

// A line's amount is what the vendor is owed, so it is whole cents — for a
// stocked item exactly as much as for anything else. computeLineAmount above is
// the ONE rule. See db/migrations/0020_inventory_receiving_fixes.sql for why
// the movement takes this amount as its value rather than recomputing it: no
// six-decimal unit cost can reproduce $1.40 from 333 units, so the value has to
// be given, not derived a second time.

/** Formats a NUMERIC(19,4) string from Postgres (e.g. "1234.0000") as
 *  "$1,234.00" — pure string surgery, never a parsed JS number (CLAUDE.md §2). */
function formatUsd(amount: string): string {
  const negative = amount.startsWith("-");
  const unsigned = negative ? amount.slice(1) : amount;
  const [wholeRaw, fracRaw = ""] = unsigned.split(".");
  const whole = wholeRaw!.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const frac = (fracRaw + "00").slice(0, 2);
  return `${negative ? "-" : ""}$${whole}.${frac}`;
}

const MONTH_ABBR = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

/** Formats an ISO date string ("2024-08-30") as "Aug 30" for the issue
 *  summary sentence — pure string surgery on the ::text-cast date the
 *  database already gave us, never a parsed JS Date (no timezone hazard
 *  between the server process and the database's clock, same rationale as
 *  invoices.ts/reports.ts's other date handling). */
function formatShortDate(isoDate: string): string {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
  if (!m) return isoDate;
  const month = MONTH_ABBR[Number(m[2]) - 1];
  if (!month) return isoDate;
  return `${month} ${Number(m[3])}`;
}

/** RLS-scoped lookup of the company's resolved (or auto-detectable) A/P
 *  account id, mirroring bill_lines_category_not_ap()'s own SQL exactly
 *  (db/migrations/0009_ap.sql) — settings first, else the sole active
 *  account literally named 'Accounts Payable'. This is a pre-check only: the
 *  app role has no EXECUTE on ensure_ap_account() (it's an internal helper,
 *  REVOKE'd from PUBLIC — only issue_bill()/pay_bills() may call it), so this
 *  is what lets a bad line be a friendly 422 here rather than a raw,
 *  unmapped "account not found"/permission-denied 500 at insert time. */
async function resolveApAccountId(client: PoolClient, companyId: string): Promise<string | null> {
  const settingsRes = await client.query<{ ap_account_id: string | null }>(
    "SELECT ap_account_id FROM company_settings WHERE company_id = $1",
    [companyId],
  );
  const settled = settingsRes.rows[0]?.ap_account_id ?? null;
  if (settled !== null) return settled;

  const fallbackRes = await client.query<{ id: string }>(
    `SELECT id FROM accounts
      WHERE company_id = $1 AND type = 'liability' AND active = true AND name = 'Accounts Payable'
      LIMIT 1`,
    [companyId],
  );
  return fallbackRes.rows[0]?.id ?? null;
}

interface CategoryAccountRow { id: string; name: string }

/** RLS-scoped lookup of one active category account by id — any type is
 *  legitimate for a bill line (expense is typical, but asset purchases are
 *  real too — see the design spec) EXCEPT the company's own A/P account,
 *  which is refused here in JS (mirrors invoices.ts's lookupIncomeAccount,
 *  but broader) rather than left solely to bill_lines_category_not_ap()'s
 *  trigger, per CLAUDE.md §12 (the trigger still exists as the real guard). */
async function lookupCategoryAccount(
  client: PoolClient, companyId: string, accountId: string, apAccountId: string | null,
): Promise<CategoryAccountRow | null> {
  if (apAccountId !== null && accountId === apAccountId) return null;
  const res = await client.query<CategoryAccountRow>(
    "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND active = true",
    [companyId, accountId],
  );
  return res.rows[0] ?? null;
}

interface ItemForLine {
  id: string;
  name: string;
  kind: string;
  inventory_account_id: string | null;
  cogs_account_id: string | null;
}

async function lookupItem(
  client: PoolClient, companyId: string, itemId: string,
): Promise<ItemForLine | null> {
  const res = await client.query<ItemForLine>(
    `SELECT id, name, kind, inventory_account_id, cogs_account_id
       FROM items WHERE company_id = $1 AND id = $2`,
    [companyId, itemId],
  );
  return res.rows[0] ?? null;
}

interface InventoryAccountRow { id: string; name: string; active: boolean }

/** Every account that is some item's inventory account — the same set
 *  check_inventory_ties() and 0019's bill_lines_item_consistent() use. Fetched
 *  once per bill body, not once per line. Carries `active` so a stocked line
 *  can refuse a deactivated account in the same words a plain category line
 *  already does (lookupCategoryAccount filters on active = true). */
async function inventoryAccountIds(
  client: PoolClient, companyId: string,
): Promise<Map<string, InventoryAccountRow>> {
  const res = await client.query<InventoryAccountRow>(
    `SELECT DISTINCT a.id, a.name, a.active
       FROM accounts a
       JOIN items i ON i.company_id = a.company_id AND i.inventory_account_id = a.id
      WHERE a.company_id = $1`,
    [companyId],
  );
  return new Map(res.rows.map((r) => [r.id, r]));
}

interface ResolvedLine {
  description: string | null;
  quantity: string;
  unitPrice: string;
  amount: string;
  categoryAccountId: string;
  itemId: string | null;
  poLineId: string | null;
  /** kind='inventory' — this line will move stock when the bill is issued.
   *  Carried out of resolveLines so the document_only refusal below can be
   *  made at DRAFT time, exactly as invoices.ts does it. */
  stocked: boolean;
}

interface LineInput {
  description?: string;
  quantity?: string;
  unitPrice: string;
  categoryAccountId?: string;
  itemId?: string;
  poLineId?: string;
}

/** Validates + resolves every line of a bill body.
 *
 *  A line with no item behaves exactly as it always has: its categoryAccountId
 *  must be a real, active account of this company that isn't the company's own
 *  A/P account (checked here in JS so a bad reference is a friendly 422, not a
 *  trigger-raised 500 — the triggers in 0009 and 0019 remain the real guards,
 *  per CLAUDE.md §12), and its amount is computed server-side to the nearest
 *  cent. The client never gets to supply `amount` directly.
 *
 *  A line that names an item resolves its own account from the item (see the
 *  "Item lines" note at the top of this file). It is rounded to whole cents
 *  like everything else — the movement takes this amount as its VALUE and
 *  derives its own per-unit cost from it, so the money and the stock are the
 *  same number without either side bending its rounding. */
async function resolveLines(
  client: PoolClient, companyId: string, apAccountId: string | null, lines: LineInput[],
): Promise<ResolvedLine[] | { status: number; error: string }> {
  const resolved: ResolvedLine[] = [];
  const inventoryAccounts = await inventoryAccountIds(client, companyId);

  for (const line of lines) {
    let item: ItemForLine | null = null;
    if (line.itemId !== undefined) {
      item = await lookupItem(client, companyId, line.itemId);
      if (!item) return NOT_FOUND_ITEM_ON_LINE;
    }

    if (line.poLineId !== undefined) {
      const poLineRes = await client.query<{ id: string }>(
        "SELECT id FROM purchase_order_lines WHERE company_id = $1 AND id = $2",
        [companyId, line.poLineId],
      );
      if (!poLineRes.rows[0]) return NOT_FOUND_PO_LINE_ON_LINE;
    }

    const quantity = line.quantity ?? "1";
    let accountId: string;
    let amount: string | null;

    if (item !== null && item.kind === "inventory") {
      // bill_lines.quantity is NOT NULL DEFAULT 1, so an omitted quantity would
      // silently receive exactly one of something. Only the API can tell the
      // difference between "one" and "they forgot", so it asks.
      if (line.quantity === undefined) {
        return {
          status: 422,
          error: `Enter how many of "${item.name}" you received.`,
        };
      }
      const inventoryAccountId = item.inventory_account_id!;
      if (line.categoryAccountId !== undefined && line.categoryAccountId !== inventoryAccountId) {
        return {
          status: 422,
          error: `"${item.name}" is something you stock, so its cost goes to its own inventory account — there's no category to pick for it.`,
        };
      }
      // A stocked line is held to the same account rules as a plain one: a
      // deactivated account is out of the pickers, so it is out here too
      // (lookupCategoryAccount's `active = true` is the plain line's version of
      // this check).
      const inventoryAccount = inventoryAccounts.get(inventoryAccountId);
      if (inventoryAccount !== undefined && !inventoryAccount.active) {
        return {
          status: 422,
          error: `The inventory account for "${item.name}" ("${inventoryAccount.name}") is turned off — turn it back on before buying more.`,
        };
      }
      accountId = inventoryAccountId;
      amount = computeLineAmount(quantity, line.unitPrice);
    } else {
      // A non-stocked item supplies its own cost account; an explicit
      // categoryAccountId still wins, so a one-off purchase can be coded
      // wherever it belongs.
      const chosen = line.categoryAccountId ?? item?.cogs_account_id ?? null;
      if (chosen === null) {
        return {
          status: 422,
          error: item
            ? `"${item.name}" doesn't have a cost account set, so pick a category for this line.`
            : "Pick a category for each line.",
        };
      }
      const inventoryAccount = inventoryAccounts.get(chosen);
      if (inventoryAccount !== undefined) {
        return {
          status: 422,
          error: `"${inventoryAccount.name}" is an inventory account — pick the item you're buying instead, so the quantity and the value move together.`,
        };
      }
      const account = await lookupCategoryAccount(client, companyId, chosen, apAccountId);
      if (!account) return NOT_CATEGORY_ACCOUNT;
      accountId = chosen;
      amount = computeLineAmount(quantity, line.unitPrice);
    }

    if (amount === null) return LINE_AMOUNT_TOO_LARGE;
    // bill_lines has CHECK (amount > 0), which would otherwise surface as the
    // generic "that doesn't fit the rules for this kind of record". A quantity
    // and a price that multiply out below half a cent is a real mistake worth
    // naming (CLAUDE.md §9).
    if (/^0(\.0+)?$/.test(amount)) {
      return {
        status: 422,
        error: "That line works out to $0.00 — check the quantity and the price.",
      };
    }
    resolved.push({
      description: line.description ?? null,
      quantity,
      unitPrice: line.unitPrice,
      amount,
      categoryAccountId: accountId,
      itemId: line.itemId ?? null,
      poLineId: line.poLineId ?? null,
      stocked: item !== null && item.kind === "inventory",
    });
  }
  return resolved;
}

/**
 * A document_only bill posts nothing, and stock can never move without a
 * posted entry behind it (0018's movements_entry_required). issue_bill()
 * refuses this too and is the real guard; this is the friendlier, EARLIER
 * version, said at draft time rather than at issue.
 *
 * Added after review: without it, bills and invoices disagreed about when they
 * refuse the same impossible thing. A document-only bill accepted a stocked
 * line with a cheerful 201 and only failed at issue — and because the UI
 * creates and issues a document-only bill in one click, that left an orphan
 * draft behind every attempt, with nothing on screen explaining what happened.
 * invoices.ts has refused at draft since Task 3; this is bills.ts catching up.
 */
const DOCUMENT_ONLY_WITH_STOCK = {
  status: 422,
  error: "This bill is recorded for tracking only, so it can't bring stock in — enter it as a normal bill if it changes the quantity on hand.",
};

interface VendorRow { id: string; terms_days: number }

async function lookupVendor(
  client: PoolClient, companyId: string, vendorId: string,
): Promise<VendorRow | null> {
  const res = await client.query<VendorRow>(
    "SELECT id, terms_days FROM vendors WHERE company_id = $1 AND id = $2 AND active = true",
    [companyId, vendorId],
  );
  return res.rows[0] ?? null;
}

/** Due date = explicit dueDate if given, else billDate + vendor's termsDays —
 *  computed in SQL (date arithmetic, not JS Date math) so there's no
 *  timezone hazard between the server process and the database's clock
 *  (mirrors invoices.ts's resolveDueDate exactly). */
async function resolveDueDate(
  client: PoolClient, billDate: string, termsDays: number, explicitDueDate: string | undefined,
): Promise<string> {
  const res = await client.query<{ due_date: string }>(
    `SELECT (coalesce($3::date, $1::date + ($2::int * interval '1 day')))::date::text AS due_date`,
    [billDate, termsDays, explicitDueDate ?? null],
  );
  return res.rows[0]!.due_date;
}

async function billLineTotal(client: PoolClient, companyId: string, billId: string): Promise<string> {
  const res = await client.query<{ total: string }>(
    "SELECT coalesce(sum(amount), 0)::numeric(19,4) AS total FROM bill_lines WHERE company_id = $1 AND bill_id = $2",
    [companyId, billId],
  );
  return res.rows[0]!.total;
}

// ---------------------------------------------------------------------------
// route registration
// ---------------------------------------------------------------------------

export function registerBillRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // POST /api/c/bills
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bills", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = billCreateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const body = parsed.data;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) {
      return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });
    }

    try {
      // See the identical note in entries.ts's POST /api/c/entries: never
      // reply.send() inside db.withCompany()'s callback — a plain
      // discriminated result is returned and replied to only once the
      // transaction has fully committed.
      const result = await db.withCompany(companyId, async (client) => {
        const vendor = await lookupVendor(client, companyId, body.vendorId);
        if (!vendor) return { ok: false as const, ...NOT_FOUND_VENDOR_FOR_BILL };

        const apAccountId = await resolveApAccountId(client, companyId);
        const resolvedLines = await resolveLines(client, companyId, apAccountId, body.lines);
        if (!Array.isArray(resolvedLines)) return { ok: false as const, ...resolvedLines };
        if ((body.documentOnly ?? false) && resolvedLines.some((l) => l.stocked)) {
          return { ok: false as const, ...DOCUMENT_ONLY_WITH_STOCK };
        }

        const dueDate = await resolveDueDate(client, body.billDate, vendor.terms_days, body.dueDate);

        // Duplicate (vendor, refNo) raises a unique-violation here (when
        // refNo is set) — deliberately let that happen and flow through
        // mapPgError below, rather than pre-checking ourselves, so the DB's
        // partial unique index stays the one source of truth (CLAUDE.md §12).
        const billRes = await client.query<{ id: string }>(
          `INSERT INTO bills
             (company_id, vendor_id, ref_no, bill_date, due_date, memo, document_only, created_by)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
           RETURNING id`,
          [companyId, body.vendorId, body.refNo ?? null, body.billDate, dueDate, body.memo ?? null,
            body.documentOnly ?? false, ownerId],
        );
        const billId = billRes.rows[0]!.id;

        for (const line of resolvedLines) {
          await client.query(
            `INSERT INTO bill_lines
               (company_id, bill_id, description, quantity, unit_price, amount,
                category_account_id, item_id, po_line_id)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
            [companyId, billId, line.description, line.quantity, line.unitPrice, line.amount,
              line.categoryAccountId, line.itemId, line.poLineId],
          );
        }

        const total = await billLineTotal(client, companyId, billId);

        return {
          ok: true as const,
          billId,
          dueDate,
          documentOnly: body.documentOnly ?? false,
          total,
          lines: resolvedLines,
        };
      });

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.code(201).send({
        billId: result.billId,
        vendorId: body.vendorId,
        refNo: body.refNo ?? null,
        billDate: body.billDate,
        dueDate: result.dueDate,
        memo: body.memo ?? null,
        documentOnly: result.documentOnly,
        status: "draft",
        total: result.total,
        lines: result.lines,
      });
    } catch (err) {
      const dup = mapDuplicateRefNo(err, body.refNo ?? null);
      if (dup) return reply.code(dup.status).send({ error: dup.error });
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create bill failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/bills/:id  (draft only — the trigger enforces this; an
  // issued bill's UPDATE raises, mapped to 409 English by errors.ts)
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/bills/:id", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const parsed = billUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const billId = parsedParams.data.id;
    const body = parsed.data;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        // Look the bill up ourselves first: RLS makes another company's bill
        // id look exactly like an unknown one (zero rows either way), and
        // this gives a clean 404 instead of an unmapped 500 (same pattern as
        // invoices.ts's PATCH).
        const existingRes = await client.query<{ id: string; document_only: boolean }>(
          "SELECT id, document_only FROM bills WHERE company_id = $1 AND id = $2",
          [companyId, billId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };

        const vendor = await lookupVendor(client, companyId, body.vendorId);
        if (!vendor) return { found: true as const, ok: false as const, ...NOT_FOUND_VENDOR_FOR_BILL };

        const apAccountId = await resolveApAccountId(client, companyId);
        const resolvedLines = await resolveLines(client, companyId, apAccountId, body.lines);
        if (!Array.isArray(resolvedLines)) {
          return { found: true as const, ok: false as const, ...resolvedLines };
        }
        // document_only is fixed at creation (the app role has no UPDATE grant
        // for it), so a draft that started document-only can't be edited into
        // one that may carry stock — mirrors invoices.ts's PATCH exactly.
        if (existing.document_only && resolvedLines.some((l) => l.stocked)) {
          return { found: true as const, ok: false as const, ...DOCUMENT_ONLY_WITH_STOCK };
        }

        const dueDate = await resolveDueDate(client, body.billDate, vendor.terms_days, body.dueDate);

        // Raises (issued-bill immutability trigger) if this bill isn't a
        // draft — deliberately let that happen and flow through mapPgError,
        // rather than pre-checking status ourselves.
        await client.query(
          `UPDATE bills SET vendor_id = $3, ref_no = $4, bill_date = $5, due_date = $6, memo = $7
            WHERE company_id = $1 AND id = $2`,
          [companyId, billId, body.vendorId, body.refNo ?? null, body.billDate, dueDate, body.memo ?? null],
        );

        // Full line replacement: delete the old set, insert the new one.
        // Same trigger protects this (bill_lines_immutable_when_issued) —
        // reaching here at all means the header UPDATE above already proved
        // the bill is still a draft.
        await client.query(
          "DELETE FROM bill_lines WHERE company_id = $1 AND bill_id = $2",
          [companyId, billId],
        );
        for (const line of resolvedLines) {
          await client.query(
            `INSERT INTO bill_lines
               (company_id, bill_id, description, quantity, unit_price, amount,
                category_account_id, item_id, po_line_id)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
            [companyId, billId, line.description, line.quantity, line.unitPrice, line.amount,
              line.categoryAccountId, line.itemId, line.poLineId],
          );
        }

        const total = await billLineTotal(client, companyId, billId);

        return { found: true as const, ok: true as const, dueDate, total, lines: resolvedLines };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_BILL);
      }
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.send({
        billId,
        vendorId: body.vendorId,
        refNo: body.refNo ?? null,
        billDate: body.billDate,
        dueDate: result.dueDate,
        memo: body.memo ?? null,
        status: "draft",
        total: result.total,
        lines: result.lines,
      });
    } catch (err) {
      const dup = mapDuplicateRefNo(err, body.refNo ?? null);
      if (dup) return reply.code(dup.status).send({ error: dup.error });
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update bill failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bills/:id/issue
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bills/:id/issue", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const billId = parsedParams.data.id;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) {
      return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });
    }

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const existingRes = await client.query<{
          id: string; document_only: boolean; due_date: string; vendor_id: string;
        }>(
          `SELECT id, document_only, due_date::text AS due_date, vendor_id
             FROM bills WHERE company_id = $1 AND id = $2`,
          [companyId, billId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };

        // issue_bill() (unlike issue_invoice()) assigns no number — bills
        // carry the vendor's own ref_no instead — so it RETURNS void; the
        // summary sentence below is built from data already on hand.
        await client.query("SELECT issue_bill($1, $2, $3)", [companyId, billId, ownerId]);

        const total = await billLineTotal(client, companyId, billId);
        const vendorRes = await client.query<{ name: string }>(
          "SELECT name FROM vendors WHERE company_id = $1 AND id = $2",
          [companyId, existing.vendor_id],
        );

        return {
          found: true as const,
          total,
          documentOnly: existing.document_only,
          dueDate: existing.due_date,
          vendorName: vendorRes.rows[0]?.name ?? "this vendor",
        };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_BILL);
      }
      const summary = result.documentOnly
        ? `Bill from ${result.vendorName} for ${formatUsd(result.total)} recorded for tracking only — no accounting impact.`
        : `Bill from ${result.vendorName} for ${formatUsd(result.total)} recorded — due ${formatShortDate(result.dueDate)}.`;
      return reply.send({ billId, summary });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "issue bill failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/bills/:id/void
  // -------------------------------------------------------------------------
  fastify.post("/api/c/bills/:id/void", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const parsedBody = voidBodySchema.safeParse(request.body ?? {});
    if (!parsedBody.success) {
      return reply.code(400).send({ error: zodMessage(parsedBody.error.issues) });
    }
    const billId = parsedParams.data.id;
    const voidDate = parsedBody.data.date ?? null;

    const ownerId = await resolveOwner(db, companyId, request.log);
    if (ownerId === null) {
      return reply.code(NO_OWNER_ERROR.status).send({ error: NO_OWNER_ERROR.error });
    }

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const existingRes = await client.query<{ id: string }>(
          "SELECT id FROM bills WHERE company_id = $1 AND id = $2",
          [companyId, billId],
        );
        if (!existingRes.rows[0]) return { found: false as const };

        await client.query(
          "SELECT void_bill($1, $2, coalesce($3::date, current_date), $4)",
          [companyId, billId, voidDate, ownerId],
        );

        return { found: true as const };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_BILL);
      }
      return reply.send({
        billId,
        summary: "Voided this bill — it no longer counts toward this vendor's balance.",
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "void bill failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // DELETE /api/c/bills/:id  (drafts only — discard, not void)
  //
  // Added by Task 4 of docs/superpowers/plans/2026-08-03-inventory.md, after
  // Task 2's review found there was no way out of the receive prefill's
  // all-drafted 409 ("Everything left on this purchase order is already on a
  // bill you haven't entered yet") except hand-editing the offending draft's
  // lines away. A DRAFT BILL HAS POSTED NOTHING — no journal entry, no stock
  // movement, no balance anywhere — so throwing one away is safe in a way that
  // voiding an issued bill is not, and it is the honest answer to "I started
  // this receipt by mistake".
  //
  // Draft-only, deliberately and twice over: this route refuses a non-draft in
  // English, and bills_immutable_when_issued (0009) refuses it at the table
  // regardless of what any route thinks (CLAUDE.md §12). The lines go first
  // because bill_lines' composite FK to bills carries no ON DELETE behavior —
  // both statements run in db.withCompany()'s single transaction, so either
  // the whole draft goes or none of it does.
  // -------------------------------------------------------------------------
  fastify.delete("/api/c/bills/:id", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const billId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        // FOR UPDATE so a concurrent issue can't slip in between the status
        // read and the delete (the trigger would still refuse it — this just
        // makes the friendly message the one the user actually sees).
        const existingRes = await client.query<{ status: string }>(
          "SELECT status FROM bills WHERE company_id = $1 AND id = $2 FOR UPDATE",
          [companyId, billId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };
        if (existing.status !== "draft") {
          return {
            found: true as const, ok: false as const, status: 409,
            error: existing.status === "void"
              ? "This bill has already been voided — there's nothing left to discard."
              : "This bill is already in your books, so it can't be thrown away — void it instead.",
          };
        }

        await client.query(
          "DELETE FROM bill_lines WHERE company_id = $1 AND bill_id = $2",
          [companyId, billId],
        );
        await client.query(
          "DELETE FROM bills WHERE company_id = $1 AND id = $2",
          [companyId, billId],
        );
        return { found: true as const, ok: true as const };
      });

      if (!result.found) return reply.code(404).send(NOT_FOUND_BILL);
      if (!result.ok) return reply.code(result.status).send({ error: result.error });
      return reply.send({
        billId,
        summary: "Threw away this draft bill. Nothing was recorded in your books — a draft never touches them.",
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "delete bill failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bills?status=&vendorId=&limit=&offset=
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bills", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedQuery = listQuerySchema.safeParse(request.query);
    if (!parsedQuery.success) {
      return reply.code(400).send({ error: zodMessage(parsedQuery.error.issues) });
    }
    const { status, vendorId, limit, offset } = parsedQuery.data;

    try {
      return await db.withCompany(companyId, async (client) => {
        // Derived entirely in SQL (state, applied, open, overdue) — one
        // query, no per-bill round trip. `state` distinguishes the two
        // sub-states of raw status='open' (partial vs paid) that the DB
        // column itself never stores (CLAUDE.md §3.4: derived, not stored).
        const rowsRes = await client.query(
          `WITH totals AS (
             SELECT b.id, b.ref_no, b.vendor_id, v.name AS vendor_name,
                    b.bill_date, b.due_date, b.memo, b.document_only, b.status,
                    coalesce(sum(bl.amount), 0)::numeric(19,4) AS total
               FROM bills b
               JOIN vendors v ON v.company_id = b.company_id AND v.id = b.vendor_id
               LEFT JOIN bill_lines bl ON bl.company_id = b.company_id AND bl.bill_id = b.id
              WHERE b.company_id = $1
              GROUP BY b.id, b.ref_no, b.vendor_id, v.name, b.bill_date, b.due_date,
                       b.memo, b.document_only, b.status
           ),
           applied_amt AS (
             SELECT bpa.bill_id, sum(bpa.amount)::numeric(19,4) AS applied
               FROM bill_payment_applications bpa
               JOIN bill_payments p ON p.company_id = bpa.company_id AND p.id = bpa.payment_id
              WHERE bpa.company_id = $1 AND p.status = 'active'
              GROUP BY bpa.bill_id
           ),
           derived AS (
             SELECT t.*, coalesce(a.applied, 0)::numeric(19,4) AS applied,
                    (t.total - coalesce(a.applied, 0))::numeric(19,4) AS open
               FROM totals t
               LEFT JOIN applied_amt a ON a.bill_id = t.id
           ),
           withstate AS (
             SELECT d.*,
                    CASE
                      WHEN status = 'draft' THEN 'draft'
                      WHEN status = 'void' THEN 'void'
                      WHEN open = 0 THEN 'paid'
                      WHEN applied > 0 THEN 'partial'
                      ELSE 'open'
                    END AS state,
                    (status = 'open' AND due_date < current_date AND open > 0) AS overdue
               FROM derived d
           )
           SELECT id, ref_no, vendor_id, vendor_name,
                  bill_date::text AS bill_date, due_date::text AS due_date,
                  memo, document_only, status, total, applied, open, state, overdue,
                  count(*) OVER() AS full_count
             FROM withstate
            WHERE ($2::text IS NULL OR state = $2)
              AND ($3::bigint IS NULL OR vendor_id = $3)
            -- qualified with the CTE name so this resolves against the
            -- original date-typed column, not the ::text alias projected
            -- above (which would break the DESC ordering: text sorts
            -- lexically, not chronologically).
            ORDER BY overdue DESC, (state IN ('open', 'partial')) DESC,
                     withstate.bill_date DESC, withstate.id DESC
            LIMIT $4 OFFSET $5`,
          [companyId, status ?? null, vendorId ?? null, limit ?? 50, offset ?? 0],
        );

        const rows = rowsRes.rows as Array<{
          id: string; ref_no: string | null; vendor_id: string; vendor_name: string;
          bill_date: string; due_date: string; memo: string | null; document_only: boolean;
          status: string; total: string; applied: string; open: string; state: string;
          overdue: boolean; full_count: string;
        }>;

        return {
          total: rows[0] ? Number(rows[0].full_count) : 0,
          bills: rows.map((r) => ({
            billId: r.id,
            refNo: r.ref_no,
            vendorId: r.vendor_id,
            vendorName: r.vendor_name,
            billDate: r.bill_date,
            dueDate: r.due_date,
            memo: r.memo,
            documentOnly: r.document_only,
            status: r.status,
            state: r.state,
            total: r.total,
            applied: r.applied,
            open: r.open,
            overdue: r.overdue,
          })),
        };
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "bills list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/bills/:id  (detail)
  // -------------------------------------------------------------------------
  fastify.get("/api/c/bills/:id", async (request, reply) => {
    const companyId = request.companyId!;
    const parsedParams = idParamSchema.safeParse(request.params);
    if (!parsedParams.success) {
      return reply.code(400).send({ error: zodMessage(parsedParams.error.issues) });
    }
    const billId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const headerRes = await client.query<{
          id: string; ref_no: string | null; vendor_id: string; status: string;
          bill_date: string; due_date: string; memo: string | null; document_only: boolean;
          posted_entry_id: string | null; void_entry_id: string | null;
          vendor_name: string; vendor_email: string | null; vendor_phone: string | null;
          vendor_address: string | null; vendor_terms_days: number; overdue: boolean;
        }>(
          // overdue is derived here (in SQL, using the database's own clock —
          // see reports.ts's identical rationale) rather than compared
          // against a JS Date, so the server process's timezone/clock can
          // never disagree with the database's.
          `SELECT b.id, b.ref_no, b.vendor_id, b.status, b.bill_date::text AS bill_date,
                  b.due_date::text AS due_date, b.memo, b.document_only,
                  b.posted_entry_id, b.void_entry_id,
                  v.name AS vendor_name, v.email AS vendor_email, v.phone AS vendor_phone,
                  v.address AS vendor_address, v.terms_days AS vendor_terms_days,
                  (b.status = 'open' AND b.due_date < current_date
                    AND bill_open_balance($1, b.id) > 0) AS overdue
             FROM bills b
             JOIN vendors v ON v.company_id = b.company_id AND v.id = b.vendor_id
            WHERE b.company_id = $1 AND b.id = $2`,
          [companyId, billId],
        );
        const header = headerRes.rows[0];
        if (!header) return { found: false as const };

        const linesRes = await client.query<{
          id: string; description: string | null; quantity: string; unit_price: string;
          amount: string; category_account_id: string; category_account_name: string;
          item_id: string | null; item_name: string | null; item_kind: string | null;
          po_line_id: string | null; po_id: string | null; po_no: string | null;
        }>(
          `SELECT l.id, l.description, l.quantity, l.unit_price, l.amount,
                  l.category_account_id, a.name AS category_account_name,
                  l.item_id, it.name AS item_name, it.kind AS item_kind,
                  l.po_line_id, pl.po_id, p.po_no
             FROM bill_lines l
             JOIN accounts a ON a.company_id = l.company_id AND a.id = l.category_account_id
             LEFT JOIN items it ON it.company_id = l.company_id AND it.id = l.item_id
             LEFT JOIN purchase_order_lines pl
                    ON pl.company_id = l.company_id AND pl.id = l.po_line_id
             LEFT JOIN purchase_orders p ON p.company_id = pl.company_id AND p.id = pl.po_id
            WHERE l.company_id = $1 AND l.bill_id = $2
            ORDER BY l.id`,
          [companyId, billId],
        );

        const totalsRes = await client.query<{ total: string; open: string; applied: string }>(
          `SELECT coalesce(sum(l.amount), 0)::numeric(19,4) AS total,
                  bill_open_balance($1, $2) AS open,
                  (coalesce(sum(l.amount), 0) - bill_open_balance($1, $2))::numeric(19,4) AS applied
             FROM bill_lines l
            WHERE l.company_id = $1 AND l.bill_id = $2`,
          [companyId, billId],
        );
        const totals = totalsRes.rows[0]!;

        const state = header.status === "draft" ? "draft"
          : header.status === "void" ? "void"
            : totals.open === "0.0000" ? "paid"
              : totals.applied !== "0.0000" ? "partial"
                : "open";

        const appsRes = await client.query<{
          id: string; payment_id: string; amount: string; payment_date: string; payment_status: string;
        }>(
          `SELECT bpa.id, bpa.payment_id, bpa.amount, p.payment_date::text AS payment_date, p.status AS payment_status
             FROM bill_payment_applications bpa
             JOIN bill_payments p ON p.company_id = bpa.company_id AND p.id = bpa.payment_id
            WHERE bpa.company_id = $1 AND bpa.bill_id = $2
            ORDER BY p.payment_date, bpa.id`,
          [companyId, billId],
        );

        return {
          found: true as const,
          billId: header.id,
          refNo: header.ref_no,
          status: header.status,
          state,
          overdue: header.overdue,
          billDate: header.bill_date,
          dueDate: header.due_date,
          memo: header.memo,
          documentOnly: header.document_only,
          postedEntryId: header.posted_entry_id,
          voidEntryId: header.void_entry_id,
          vendor: {
            id: header.vendor_id,
            name: header.vendor_name,
            email: header.vendor_email,
            phone: header.vendor_phone,
            address: header.vendor_address,
            termsDays: header.vendor_terms_days,
          },
          lines: linesRes.rows.map((l) => ({
            id: l.id,
            description: l.description,
            quantity: l.quantity,
            unitPrice: l.unit_price,
            amount: l.amount,
            categoryAccountId: l.category_account_id,
            categoryAccountName: l.category_account_name,
            itemId: l.item_id,
            itemName: l.item_name,
            itemKind: l.item_kind,
            poLineId: l.po_line_id,
            poId: l.po_id,
            poNo: l.po_no,
          })),
          applications: appsRes.rows.map((a) => ({
            id: a.id,
            paymentId: a.payment_id,
            amount: a.amount,
            paymentDate: a.payment_date,
            paymentStatus: a.payment_status,
          })),
          total: totals.total,
          applied: totals.applied,
          open: totals.open,
        };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_BILL);
      }
      const { found: _found, ...body } = result;
      return reply.send(body);
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "bill detail failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });
}
