// Invoices API — Task 2 of docs/superpowers/plans/2026-07-31-ar.md. Same
// route-module pattern as entries.ts/reports.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/imports.ts/close.ts: there's no login in v1.
//
// Documents sit ON TOP of the ledger (docs/superpowers/specs/2026-07-31-ar-design.md):
// a draft invoice has no accounting impact at all. issue_invoice() (a
// SECURITY DEFINER function from db/migrations/0005_ar.sql) is what actually
// posts (or, for document_only invoices carrying already-migrated history,
// just assigns a number with no posting). Every derived amount here (total,
// applied, open, state) is computed in SQL at query time from invoice_lines /
// payment_applications — CLAUDE.md §3.4, never stored, never cached.
//
// ---------------------------------------------------------------------------
// Rounding rule (binding — see the plan's Task 2 description):
// invoice_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), matching how a person reading an invoice total expects "half a
// cent" to resolve. quantity 3 * unit_price 33.33 = 99.99 exactly, no
// rounding needed. See computeLineAmount()/toMicros()/fromMicros() below for
// the actual integer-only implementation.
// ---------------------------------------------------------------------------
//
// ---------------------------------------------------------------------------
// Item lines (docs/superpowers/specs/2026-08-03-inventory-design.md, Task 3).
//
// An invoice line may name an item. Picking one fills the price from
// items.sales_price and targets the item's own income account; the seller never
// has to think about cost of sales, and never sees the word.
//
//   - a STOCKED item (kind='inventory') moves stock. issue_invoice()
//     (db/migrations/0023_inventory_selling_builds.sql) records a sale movement
//     at the CURRENT AVERAGE and posts Dr <the item's cost account> /
//     Cr <the item's inventory account> on the SAME journal entry, in the same
//     transaction — so 0018's per-account check and 0022's stock-backed check
//     both see a complete fact.
//   - a NON-STOCKED item (non_inventory / service) is exactly today's line with
//     the price and category prefilled: no movement, no cost of sales.
//   - a line with NO item is completely unchanged.
//
// Three refusals live here (the first two are also enforced in the database,
// per §12; the third is API-only for the reason given):
//   - a document_only invoice with a stocked line. It posts NOTHING, and 0018
//     forbids a movement without a posted entry, so "sold but never posted" has
//     to be impossible. issue_invoice() is the real guard.
//   - selling more than is on hand. record_movement() raises it in English,
//     naming the item and both quantities.
//   - a stocked-item line with no quantity. invoice_lines.quantity is NOT NULL
//     DEFAULT 1, so the database cannot tell "omitted" from "one" — only the
//     API can, and silently selling one unit of something is worse than asking.
//     (Identical reasoning to bills.ts's receiving side.)
// ---------------------------------------------------------------------------

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 entries.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(),
);

// blankToUndefined trims BEFORE checking for blankness, so a whitespace-only
// input ("   ") collapses to undefined/NULL rather than surviving as a
// stored empty string (sweep fix — see lib/textNormalize.ts's header for the
// full history: this exact bug was already fixed in bills.ts/customers.ts/
// vendors.ts; it existed here too, unfixed, until this sweep).
const memoSchema = z.preprocess(blankToUndefined, z.string().max(1000).optional());
const descriptionSchema = z.preprocess(blankToUndefined, z.string().max(500).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 invoice_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 invoice_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 — items.sales_price is numeric(19,4) and sub-cent prices are real, which
// is the same reason bills.ts accepts four on the purchasing side. 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 line (unchanged, same message) and the 4-decimal one for an item
  // line. Optional only when the line names an item: that item's sales price
  // is what fills it in.
  unitPrice: z.string().optional(),
  // Optional only when the line names an item that carries its own income
  // account.
  incomeAccountId: idSchema.optional(),
  itemId: idSchema.optional(),
  // Sales tax (db/migrations/0015_sales_tax.sql): per-line flag, stored even
  // when the invoice carries no rate (flag kept, no tax charged) — the UI
  // defaults it on when a rate is picked; the API default matches the
  // column's own DEFAULT false.
  taxable: z.boolean().optional(),
}).superRefine((line, ctx) => {
  if (line.unitPrice === undefined) {
    if (line.itemId === undefined) {
      ctx.addIssue({ code: "custom", path: ["unitPrice"], message: "Enter a price for each line." });
    }
  } else {
    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.incomeAccountId === undefined) {
    ctx.addIssue({
      code: "custom", path: ["incomeAccountId"],
      message: "Pick an income category for each line — not a money account.",
    });
  }
});

// Sales tax rate reference: null and absent both mean "no tax" — the invoice
// form always resubmits the whole header (same convention as every other
// PATCH-replaces-all field here), so there's no "omitted = keep" ambiguity.
const taxRateIdSchema = z.preprocess(
  (v) => (v === null ? undefined : v),
  idSchema.optional(),
);

const invoiceCreateSchema = z.object({
  customerId: idSchema,
  invoiceDate: isoDateSchema,
  dueDate: optionalIsoDate,
  memo: memoSchema,
  documentOnly: z.boolean().optional(),
  taxRateId: taxRateIdSchema,
  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/0005_ar.sql
// only grants app UPDATE on (customer_id, invoice_date, due_date, memo), so
// the flag is fixed for the life of the invoice, set only at creation.
// (0015 extends that grant with tax_rate_id/tax_amount, both draft-editable.)
const invoiceUpdateSchema = z.object({
  customerId: idSchema,
  invoiceDate: isoDateSchema,
  dueDate: optionalIsoDate,
  memo: memoSchema,
  taxRateId: taxRateIdSchema,
  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(),
  customerId: 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_INVOICE = { error: "We couldn't find that invoice." };
const NOT_FOUND_CUSTOMER_FOR_INVOICE = { status: 422, error: "Pick a customer for this invoice." };
const NOT_INCOME_ACCOUNT = {
  status: 422,
  error: "Pick an income category for each line — not a money account.",
};
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 LINE_AMOUNT_ZERO = {
  status: 422,
  error: "That line works out to $0.00 — check the quantity and the price.",
};

// Sales tax validation errors (db/migrations/0015_sales_tax.sql; the DB's own
// guards — the enabled gate and the recompute check inside issue_invoice() —
// stay the real enforcement per CLAUDE.md §12; these are the friendlier
// draft-time versions).
const TAX_DISABLED = {
  status: 422,
  error: "Sales tax is turned off for this company — turn it on in Settings before adding tax to an invoice.",
};
const NOT_FOUND_TAX_RATE = { status: 422, error: "We couldn't find that tax rate." };
const TAX_RATE_INACTIVE = {
  status: 422,
  error: "That tax rate is inactive — reactivate it in Settings or pick another rate.",
};
const CUSTOMER_TAX_EXEMPT = {
  status: 422,
  error: "This customer is tax-exempt, so sales tax can't be added to their invoices.",
};

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

/** Mirrors entries.ts/close.ts's resolveOwner — every invoice/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
// close.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}`;
}

// invoice_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 for the worked example: 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)
}

/**
 * Sales tax = taxable subtotal * rate percent / 100, rounded HALF-UP to the
 * nearest cent — the same integer-only technique as computeLineAmount above,
 * and by construction the IDENTICAL number issue_invoice() recomputes in
 * db/migrations/0015_sales_tax.sql (`round(taxable_total * rate / 100, 2)`:
 * Postgres numeric round() is half-away-from-zero, which for these
 * non-negative amounts is exactly half-up). A mismatch there fails the issue
 * loudly, so this equivalence is load-bearing; the integration tests prove it
 * end-to-end across .005 boundaries (e.g. 5% of 10.10 = 0.505 -> 0.51).
 *
 * Scales: taxableMicros = dollars*1e4; toMicros(ratePercent) = percent*1e4;
 * their product = dollars*percent*1e8 = taxDollars*100*1e8 = taxCents*1e8, so
 * one half-up division by 1e8 yields whole tax cents exactly. Both operands
 * are non-negative (line amounts and the 0-100 CHECK'd rate), so half-up ==
 * half-away-from-zero. No overflow guard is needed: tax <= taxable subtotal
 * (rate <= 100%), which is already bounded per-line by MAX_LINE_AMOUNT_CENTS.
 */
function computeTaxAmount(taxableMicros: bigint, ratePercent: string): string {
  const rateScaled = toMicros(ratePercent); // percent * 1e4
  const DIVISOR = 100_000_000n; // 1e8 (see scale derivation above)
  const HALF = DIVISOR / 2n;
  const cents = (taxableMicros * rateScaled + HALF) / DIVISOR;
  return fromMicros(cents * 100n);
}

const ZERO_TAX = "0.0000";

/** 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}`;
}

interface IncomeAccountRow { id: string; name: string }

/** RLS-scoped lookup of one active INCOME account by id, within the caller's
 *  transaction — mirrors entries.ts's lookupAccount, but fixed to type
 *  'income' since that's the only kind an invoice line may post to. */
async function lookupIncomeAccount(
  client: PoolClient, companyId: string, accountId: string,
): Promise<IncomeAccountRow | null> {
  const res = await client.query<IncomeAccountRow>(
    "SELECT id, name FROM accounts WHERE company_id = $1 AND id = $2 AND type = 'income' AND active = true",
    [companyId, accountId],
  );
  return res.rows[0] ?? null;
}

interface ItemForLine {
  id: string;
  name: string;
  kind: string;
  income_account_id: string | null;
  sales_price: string | null;
  active: boolean;
}

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

interface ResolvedLine {
  description: string | null;
  quantity: string;
  unitPrice: string;
  amount: string;
  incomeAccountId: string;
  itemId: string | null;
  /** kind='inventory' — this line will move stock when the invoice is issued. */
  stocked: boolean;
  taxable: boolean;
}

interface LineInput {
  description?: string;
  quantity?: string;
  unitPrice?: string;
  incomeAccountId?: string;
  itemId?: string;
  taxable?: boolean;
}

/** Validates + resolves every line of an invoice body: each incomeAccountId
 *  must be a real, active, income-type account of this company (checked here
 *  in JS so a bad reference is a friendly 422, not a trigger-raised 500 —
 *  the trigger in db/migrations/0005_ar.sql still exists as the real guard,
 *  per CLAUDE.md §12), and each line's amount is computed server-side (see
 *  computeLineAmount) — the client never gets to supply `amount` directly.
 *
 *  A line that names an item fills in what it did not say: the price from
 *  items.sales_price and the category from the item's own income account. An
 *  explicit value still wins in both cases, so a one-off price or a differently
 *  coded sale stays possible (mirrors bills.ts's treatment of categories). */
async function resolveLines(
  client: PoolClient, companyId: string, lines: LineInput[],
): Promise<ResolvedLine[] | { status: number; error: string }> {
  const resolved: ResolvedLine[] = [];
  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 (!item.active) {
        return { status: 422, error: `"${item.name}" is turned off — turn it back on before selling it.` };
      }
    }
    const stocked = item !== null && item.kind === "inventory";

    // invoice_lines.quantity is NOT NULL DEFAULT 1, so an omitted quantity
    // would silently sell exactly one of something. Only the API can tell the
    // difference between "one" and "they forgot", so it asks.
    if (stocked && line.quantity === undefined) {
      return { status: 422, error: `Enter how many of "${item!.name}" you sold.` };
    }

    const unitPrice = line.unitPrice ?? item?.sales_price ?? null;
    if (unitPrice === null) {
      return {
        status: 422,
        error: `"${item!.name}" doesn't have a price set, so enter one for this line.`,
      };
    }

    const incomeAccountId = line.incomeAccountId ?? item?.income_account_id ?? null;
    if (incomeAccountId === null) {
      return {
        status: 422,
        error: `"${item!.name}" doesn't have an income category set, so pick one for this line.`,
      };
    }

    const account = await lookupIncomeAccount(client, companyId, incomeAccountId);
    if (!account) return NOT_INCOME_ACCOUNT;

    const quantity = line.quantity ?? "1";
    const amount = computeLineAmount(quantity, unitPrice);
    if (amount === null) return LINE_AMOUNT_TOO_LARGE;
    // invoice_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). Reachable now that a price can come
    // from an item whose sales_price is zero.
    if (/^0(\.0+)?$/.test(amount)) return LINE_AMOUNT_ZERO;

    resolved.push({
      description: line.description ?? null,
      quantity,
      unitPrice,
      amount,
      incomeAccountId,
      itemId: line.itemId ?? null,
      stocked,
      taxable: line.taxable ?? false,
    });
  }
  return resolved;
}

/** A document_only invoice posts nothing, and stock can never move without a
 *  posted entry behind it (0018's movements_entry_required). issue_invoice()
 *  refuses this too and is the real guard; this is the friendlier, earlier
 *  version, said at draft time rather than at issue. */
const DOCUMENT_ONLY_WITH_STOCK = {
  status: 422,
  error: "This invoice is recorded for tracking only, so it can't move stock — enter it as a normal invoice if it changes the quantity on hand.",
};

interface CustomerRow { id: string; terms_days: number; tax_exempt: boolean }

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

interface TaxRateRow { id: string; name: string; rate: string; active: boolean }

/**
 * Validates a draft's tax rate reference and returns the rate row, or a
 * friendly {status, error} refusal. Checks, in order: sales tax must be ON
 * for this company (the DB re-checks at issue — 0015's enabled gate — but a
 * disabled company should be refused at draft time, in English, not at
 * issue); the rate must exist for THIS company (RLS + the composite FK make
 * another company's rate look exactly like an unknown one); it must be
 * active; and the customer must not be tax-exempt. Exemption is re-checked
 * at ISSUE time too (see the issue route), because a customer can legally be
 * flipped to exempt while taxed drafts exist.
 */
async function resolveTaxRate(
  client: PoolClient, companyId: string, taxRateId: string, customer: CustomerRow,
): Promise<TaxRateRow | { status: number; error: string }> {
  const enabledRes = await client.query<{ enabled: boolean }>(
    "SELECT sales_tax_enabled AS enabled FROM company_settings WHERE company_id = $1",
    [companyId],
  );
  if (enabledRes.rows[0]?.enabled !== true) return TAX_DISABLED;

  const rateRes = await client.query<TaxRateRow>(
    "SELECT id, name, rate::text AS rate, active FROM tax_rates WHERE company_id = $1 AND id = $2",
    [companyId, taxRateId],
  );
  const rate = rateRes.rows[0];
  if (!rate) return NOT_FOUND_TAX_RATE;
  if (!rate.active) return TAX_RATE_INACTIVE;
  if (customer.tax_exempt) return CUSTOMER_TAX_EXEMPT;
  return rate;
}

/** Taxable subtotal of a resolved line set, as BigInt micros — the input to
 *  computeTaxAmount(). Pure BigInt summation of the already-computed
 *  numeric(19,4) line amount strings; never a JS number (CLAUDE.md §2). */
function taxableSubtotalMicros(lines: ResolvedLine[]): bigint {
  let total = 0n;
  for (const line of lines) {
    if (line.taxable) total += toMicros(line.amount);
  }
  return total;
}

/** Due date = explicit dueDate if given, else invoiceDate + customer'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. */
async function resolveDueDate(
  client: PoolClient, invoiceDate: string, termsDays: number, explicitDueDate: string | undefined,
): Promise<string> {
  const res = await client.query<{ due_date: string }>(
    // date + interval yields a timestamp, so the whole coalesce is cast to
    // ::date FIRST (dropping any time-of-day component) and only then to
    // ::text — otherwise a defaulted (no explicit dueDate) result would come
    // back as "2024-06-01 00:00:00" instead of a plain "2024-06-01".
    `SELECT (coalesce($3::date, $1::date + ($2::int * interval '1 day')))::date::text AS due_date`,
    [invoiceDate, termsDays, explicitDueDate ?? null],
  );
  return res.rows[0]!.due_date;
}

interface InvoiceTotals {
  subtotal: string;
  taxableSubtotal: string;
  taxAmount: string;
  total: string;
  taxRate: { id: string; name: string; ratePercent: string } | null;
}

/** The tax breakdown every invoice payload carries (design spec: {subtotal,
 *  taxableSubtotal, taxAmount, total} + the rate when set) — one SQL query,
 *  summed in NUMERIC, never in JS. total is the GRAND total (lines + tax);
 *  every existing document has tax_amount 0, so pre-tax totals are
 *  bit-for-bit unchanged. */
async function invoiceTotals(client: PoolClient, companyId: string, invoiceId: string): Promise<InvoiceTotals> {
  const res = await client.query<{
    subtotal: string; taxable_subtotal: string; tax_amount: string; total: string;
    tax_rate_id: string | null; tax_rate_name: string | null; tax_rate_percent: string | null;
  }>(
    `SELECT coalesce(sum(l.amount), 0)::numeric(19,4) AS subtotal,
            coalesce(sum(l.amount) FILTER (WHERE l.taxable), 0)::numeric(19,4) AS taxable_subtotal,
            i.tax_amount::text AS tax_amount,
            (coalesce(sum(l.amount), 0) + i.tax_amount)::numeric(19,4) AS total,
            i.tax_rate_id, tr.name AS tax_rate_name, tr.rate::text AS tax_rate_percent
       FROM invoices i
       LEFT JOIN invoice_lines l ON l.company_id = i.company_id AND l.invoice_id = i.id
       LEFT JOIN tax_rates tr ON tr.company_id = i.company_id AND tr.id = i.tax_rate_id
      WHERE i.company_id = $1 AND i.id = $2
      GROUP BY i.tax_amount, i.tax_rate_id, tr.name, tr.rate`,
    [companyId, invoiceId],
  );
  const r = res.rows[0]!;
  return {
    subtotal: r.subtotal,
    taxableSubtotal: r.taxable_subtotal,
    taxAmount: r.tax_amount,
    total: r.total,
    taxRate: r.tax_rate_id === null
      ? null
      : { id: r.tax_rate_id, name: r.tax_rate_name!, ratePercent: r.tax_rate_percent! },
  };
}

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

export function registerInvoiceRoutes(fastify: FastifyInstance, db: Db): void {
  // -------------------------------------------------------------------------
  // POST /api/c/invoices
  // -------------------------------------------------------------------------
  fastify.post("/api/c/invoices", async (request, reply) => {
    const companyId = request.companyId!;
    const parsed = invoiceCreateSchema.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 customer = await lookupCustomer(client, companyId, body.customerId);
        if (!customer) return { ok: false as const, ...NOT_FOUND_CUSTOMER_FOR_INVOICE };

        const resolvedLines = await resolveLines(client, companyId, 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 };
        }

        // Server-computed tax (never client-supplied, exactly like line
        // amounts): taxable subtotal * rate, integer-cents HALF-UP — see
        // computeTaxAmount(). issue_invoice() independently recomputes and
        // refuses on mismatch (0015's belt-and-braces guard).
        let taxRate: TaxRateRow | null = null;
        if (body.taxRateId !== undefined) {
          const resolved = await resolveTaxRate(client, companyId, body.taxRateId, customer);
          if (!("rate" in resolved)) return { ok: false as const, ...resolved };
          taxRate = resolved;
        }
        const taxAmount = taxRate
          ? computeTaxAmount(taxableSubtotalMicros(resolvedLines), taxRate.rate)
          : ZERO_TAX;

        const dueDate = await resolveDueDate(client, body.invoiceDate, customer.terms_days, body.dueDate);

        const invoiceRes = await client.query<{ id: string }>(
          `INSERT INTO invoices
             (company_id, customer_id, invoice_date, due_date, memo, document_only, created_by,
              tax_rate_id, tax_amount)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
           RETURNING id`,
          [companyId, body.customerId, body.invoiceDate, dueDate, body.memo ?? null,
            body.documentOnly ?? false, ownerId, taxRate?.id ?? null, taxAmount],
        );
        const invoiceId = invoiceRes.rows[0]!.id;

        for (const line of resolvedLines) {
          await client.query(
            `INSERT INTO invoice_lines
               (company_id, invoice_id, description, quantity, unit_price, amount,
                income_account_id, taxable, item_id)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
            [companyId, invoiceId, line.description, line.quantity, line.unitPrice, line.amount,
              line.incomeAccountId, line.taxable, line.itemId],
          );
        }

        const totals = await invoiceTotals(client, companyId, invoiceId);

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

      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.code(201).send({
        invoiceId: result.invoiceId,
        customerId: body.customerId,
        invoiceDate: body.invoiceDate,
        dueDate: result.dueDate,
        memo: body.memo ?? null,
        documentOnly: result.documentOnly,
        status: "draft",
        subtotal: result.totals.subtotal,
        taxableSubtotal: result.totals.taxableSubtotal,
        taxAmount: result.totals.taxAmount,
        taxRate: result.totals.taxRate,
        total: result.totals.total,
        lines: result.lines,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "create invoice failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // PATCH /api/c/invoices/:id  (draft only — the trigger enforces this;
  // an issued invoice's UPDATE raises, mapped to 409 English by errors.ts)
  // -------------------------------------------------------------------------
  fastify.patch("/api/c/invoices/: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 = invoiceUpdateSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({ error: zodMessage(parsed.error.issues) });
    }
    const invoiceId = parsedParams.data.id;
    const body = parsed.data;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        // Look the invoice up ourselves first: RLS makes another company's
        // invoice 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 entries.ts's POST /:id/reverse).
        const existingRes = await client.query<{ id: string; document_only: boolean }>(
          "SELECT id, document_only FROM invoices WHERE company_id = $1 AND id = $2",
          [companyId, invoiceId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };

        const customer = await lookupCustomer(client, companyId, body.customerId);
        if (!customer) return { found: true as const, ok: false as const, ...NOT_FOUND_CUSTOMER_FOR_INVOICE };

        const resolvedLines = await resolveLines(client, companyId, 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
        // on it), so this reads what the invoice actually is, not what the body
        // says.
        if (existing.document_only && resolvedLines.some((l) => l.stocked)) {
          return { found: true as const, ok: false as const, ...DOCUMENT_ONLY_WITH_STOCK };
        }

        // Recompute tax on EVERY draft mutation (lines, rate, or customer may
        // all have changed) — same server-side computation as create.
        let taxRate: TaxRateRow | null = null;
        if (body.taxRateId !== undefined) {
          const resolved = await resolveTaxRate(client, companyId, body.taxRateId, customer);
          if (!("rate" in resolved)) return { found: true as const, ok: false as const, ...resolved };
          taxRate = resolved;
        }
        const taxAmount = taxRate
          ? computeTaxAmount(taxableSubtotalMicros(resolvedLines), taxRate.rate)
          : ZERO_TAX;

        const dueDate = await resolveDueDate(client, body.invoiceDate, customer.terms_days, body.dueDate);

        // Raises (issued-invoice immutability trigger) if this invoice isn't
        // a draft — deliberately let that happen and flow through
        // mapPgError, per the plan ("issued -> 409 English from the trigger
        // mapping"), rather than pre-checking status ourselves.
        await client.query(
          `UPDATE invoices SET customer_id = $3, invoice_date = $4, due_date = $5, memo = $6,
                  tax_rate_id = $7, tax_amount = $8
            WHERE company_id = $1 AND id = $2`,
          [companyId, invoiceId, body.customerId, body.invoiceDate, dueDate, body.memo ?? null,
            taxRate?.id ?? null, taxAmount],
        );

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

        const totals = await invoiceTotals(client, companyId, invoiceId);

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

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_INVOICE);
      }
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      return reply.send({
        invoiceId,
        customerId: body.customerId,
        invoiceDate: body.invoiceDate,
        dueDate: result.dueDate,
        memo: body.memo ?? null,
        status: "draft",
        subtotal: result.totals.subtotal,
        taxableSubtotal: result.totals.taxableSubtotal,
        taxAmount: result.totals.taxAmount,
        taxRate: result.totals.taxRate,
        total: result.totals.total,
        lines: result.lines,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "update invoice failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/invoices/:id/issue
  // -------------------------------------------------------------------------
  fastify.post("/api/c/invoices/: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 invoiceId = 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; status: string; tax_rate_id: string | null;
          tax_amount: string; customer_tax_exempt: boolean; tax_rate_percent: string | null;
        }>(
          // FOR SHARE OF c: the exemption re-check below is only airtight if
          // the customer row can't change under us — without the lock, a
          // concurrent customer PATCH flipping tax_exempt=true could commit
          // between this SELECT and this transaction's COMMIT, posting tax
          // for a now-exempt customer. The share lock makes that concurrent
          // UPDATE block until the issue commits (or vice versa), so the
          // check and the posting always agree. (Locking `c` is legal here:
          // only tax_rates is on the nullable side of an outer join.)
          `SELECT i.id, i.document_only, i.status, i.tax_rate_id, i.tax_amount::text AS tax_amount,
                  c.tax_exempt AS customer_tax_exempt, tr.rate::text AS tax_rate_percent
             FROM invoices i
             JOIN customers c ON c.company_id = i.company_id AND c.id = i.customer_id
             LEFT JOIN tax_rates tr ON tr.company_id = i.company_id AND tr.id = i.tax_rate_id
            WHERE i.company_id = $1 AND i.id = $2
              FOR SHARE OF c`,
          [companyId, invoiceId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };

        if (existing.status === "draft" && existing.tax_rate_id !== null) {
          // Exemption is validated at DRAFT time AND re-checked here at ISSUE
          // time: a customer may legally be flipped to exempt while taxed
          // drafts exist (the customers API allows it — refusing there would
          // strand the flip behind every stale draft), so issue is the
          // airtight gate. Nothing posts for an exempt customer with a rate.
          if (existing.customer_tax_exempt) {
            return { found: true as const, ok: false as const, ...CUSTOMER_TAX_EXEMPT };
          }

          // Self-heal the stored tax before issuing: a rate's percent is
          // editable while ONLY drafts reference it (0015's freeze trigger),
          // so a draft saved before such an edit can carry a stale
          // tax_amount. Drafts recompute against the current percent at
          // issue (spec) — recompute here, exactly as at draft time, and
          // update the still-draft row when it differs. issue_invoice()'s
          // own recompute-and-refuse guard stays the real enforcement; after
          // this, a mismatch there can only mean a server bug (mapped 500).
          const taxableRes = await client.query<{ taxable: string }>(
            `SELECT coalesce(sum(amount) FILTER (WHERE taxable), 0)::numeric(19,4) AS taxable
               FROM invoice_lines WHERE company_id = $1 AND invoice_id = $2`,
            [companyId, invoiceId],
          );
          const expectedTax = computeTaxAmount(
            toMicros(taxableRes.rows[0]!.taxable), existing.tax_rate_percent!,
          );
          if (expectedTax !== existing.tax_amount) {
            await client.query(
              "UPDATE invoices SET tax_amount = $3 WHERE company_id = $1 AND id = $2",
              [companyId, invoiceId, expectedTax],
            );
          }
        }

        const issueRes = await client.query<{ issue_invoice: string }>(
          "SELECT issue_invoice($1, $2, $3) AS issue_invoice",
          [companyId, invoiceId, ownerId],
        );
        const invoiceNo = issueRes.rows[0]!.issue_invoice;
        const totals = await invoiceTotals(client, companyId, invoiceId);

        return {
          found: true as const, ok: true as const, invoiceNo, totals,
          documentOnly: existing.document_only,
        };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_INVOICE);
      }
      if (!result.ok) {
        return reply.code(result.status).send({ error: result.error });
      }
      // The grand total (lines + tax) is what the customer owes; when tax is
      // present the summary names it (design spec: "… includes $84.56 CA
      // Sales Tax."). The document-only sentence stays tax-silent on purpose
      // — its tax fields are display facts on migrated history, and "no
      // accounting impact" is the load-bearing part of that sentence.
      const amountStr = formatUsd(result.totals.total);
      const taxClause =
        !result.documentOnly && result.totals.taxAmount !== ZERO_TAX && result.totals.taxRate
          ? ` — includes ${formatUsd(result.totals.taxAmount)} ${result.totals.taxRate.name}.`
          : ".";
      const summary = result.documentOnly
        ? `Invoice #${result.invoiceNo} for ${amountStr} recorded for tracking only — no accounting impact.`
        : `Invoice #${result.invoiceNo} for ${amountStr} sent to the books${taxClause}`;
      return reply.send({ invoiceId, invoiceNo: result.invoiceNo, summary });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "issue invoice failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // POST /api/c/invoices/:id/void
  // -------------------------------------------------------------------------
  fastify.post("/api/c/invoices/: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 invoiceId = 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; invoice_no: string | null }>(
          "SELECT id, invoice_no FROM invoices WHERE company_id = $1 AND id = $2",
          [companyId, invoiceId],
        );
        const existing = existingRes.rows[0];
        if (!existing) return { found: false as const };

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

        return { found: true as const, invoiceNo: existing.invoice_no };
      });

      if (!result.found) {
        return reply.code(404).send(NOT_FOUND_INVOICE);
      }
      return reply.send({
        invoiceId,
        summary: `Voided invoice #${result.invoiceNo} — it no longer counts toward this customer's balance.`,
      });
    } catch (err) {
      const mapped = mapPgError(err);
      if (mapped.status === 500) request.log.error({ err }, "void invoice failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/invoices?status=&customerId=&limit=&offset=
  // -------------------------------------------------------------------------
  fastify.get("/api/c/invoices", 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, customerId, limit, offset } = parsedQuery.data;

    try {
      return await db.withCompany(companyId, async (client) => {
        // Derived entirely in SQL (state, applied, open, overdue) — one
        // query, no per-invoice 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 (
             -- total is the GRAND total (lines + tax_amount) — the number a
             -- customer actually owes. tax_amount is 0 on every pre-tax
             -- document, so untaxed totals are unchanged.
             SELECT i.id, i.invoice_no, i.customer_id, c.name AS customer_name,
                    i.invoice_date, i.due_date, i.memo, i.document_only, i.status,
                    (coalesce(sum(il.amount), 0) + i.tax_amount)::numeric(19,4) AS total
               FROM invoices i
               JOIN customers c ON c.company_id = i.company_id AND c.id = i.customer_id
               LEFT JOIN invoice_lines il ON il.company_id = i.company_id AND il.invoice_id = i.id
              WHERE i.company_id = $1
              GROUP BY i.id, i.invoice_no, i.customer_id, c.name, i.invoice_date, i.due_date,
                       i.memo, i.document_only, i.status, i.tax_amount
           ),
           applied_amt AS (
             SELECT pa.invoice_id, sum(pa.amount)::numeric(19,4) AS applied
               FROM payment_applications pa
               JOIN payments p ON p.company_id = pa.company_id AND p.id = pa.payment_id
              WHERE pa.company_id = $1 AND p.status = 'active'
              GROUP BY pa.invoice_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.invoice_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, invoice_no, customer_id, customer_name,
                  invoice_date::text AS invoice_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 customer_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.invoice_date DESC, withstate.id DESC
            LIMIT $4 OFFSET $5`,
          [companyId, status ?? null, customerId ?? null, limit ?? 50, offset ?? 0],
        );

        const rows = rowsRes.rows as Array<{
          id: string; invoice_no: string | null; customer_id: string; customer_name: string;
          invoice_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,
          invoices: rows.map((r) => ({
            invoiceId: r.id,
            invoiceNo: r.invoice_no,
            customerId: r.customer_id,
            customerName: r.customer_name,
            invoiceDate: r.invoice_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 }, "invoices list failed");
      return reply.code(mapped.status).send({ error: mapped.message });
    }
  });

  // -------------------------------------------------------------------------
  // GET /api/c/invoices/:id  (detail)
  // -------------------------------------------------------------------------
  fastify.get("/api/c/invoices/: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 invoiceId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const headerRes = await client.query<{
          id: string; invoice_no: string | null; customer_id: string; status: string;
          invoice_date: string; due_date: string; memo: string | null; document_only: boolean;
          posted_entry_id: string | null; void_entry_id: string | null;
          customer_name: string; customer_email: string | null; customer_phone: string | null;
          customer_address: string | null; customer_terms_days: number;
          customer_tax_exempt: boolean; 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 i.id, i.invoice_no, i.customer_id, i.status, i.invoice_date::text AS invoice_date,
                  i.due_date::text AS due_date, i.memo, i.document_only,
                  i.posted_entry_id, i.void_entry_id,
                  c.name AS customer_name, c.email AS customer_email, c.phone AS customer_phone,
                  c.address AS customer_address, c.terms_days AS customer_terms_days,
                  c.tax_exempt AS customer_tax_exempt,
                  (i.status = 'open' AND i.due_date < current_date
                    AND invoice_open_balance($1, i.id) > 0) AS overdue
             FROM invoices i
             JOIN customers c ON c.company_id = i.company_id AND c.id = i.customer_id
            WHERE i.company_id = $1 AND i.id = $2`,
          [companyId, invoiceId],
        );
        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; income_account_id: string; income_account_name: string;
          taxable: boolean; item_id: string | null; item_name: string | null;
          item_kind: string | null;
        }>(
          `SELECT l.id, l.description, l.quantity, l.unit_price, l.amount,
                  l.income_account_id, a.name AS income_account_name, l.taxable,
                  l.item_id, it.name AS item_name, it.kind AS item_kind
             FROM invoice_lines l
             JOIN accounts a ON a.company_id = l.company_id AND a.id = l.income_account_id
             LEFT JOIN items it ON it.company_id = l.company_id AND it.id = l.item_id
            WHERE l.company_id = $1 AND l.invoice_id = $2
            ORDER BY l.id`,
          [companyId, invoiceId],
        );

        // subtotal/taxableSubtotal/taxAmount/total (grand) come from the
        // shared invoiceTotals(); open comes from invoice_open_balance()
        // (tax-inclusive since 0015), and applied = GRAND total − open — the
        // pre-tax version of this arithmetic yielded applied = −tax for an
        // unpaid taxed invoice (carry-over fix from the Task 1 review).
        const totals = await invoiceTotals(client, companyId, invoiceId);
        const openAppliedRes = await client.query<{ open: string; applied: string }>(
          `SELECT invoice_open_balance($1, $2) AS open,
                  ((SELECT coalesce(sum(l.amount), 0) FROM invoice_lines l
                     WHERE l.company_id = $1 AND l.invoice_id = $2)
                   + (SELECT tax_amount FROM invoices WHERE company_id = $1 AND id = $2)
                   - invoice_open_balance($1, $2))::numeric(19,4) AS applied`,
          [companyId, invoiceId],
        );
        const openApplied = openAppliedRes.rows[0]!;

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

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

        return {
          found: true as const,
          invoiceId: header.id,
          invoiceNo: header.invoice_no,
          status: header.status,
          state,
          overdue: header.overdue,
          invoiceDate: header.invoice_date,
          dueDate: header.due_date,
          memo: header.memo,
          documentOnly: header.document_only,
          postedEntryId: header.posted_entry_id,
          voidEntryId: header.void_entry_id,
          customer: {
            id: header.customer_id,
            name: header.customer_name,
            email: header.customer_email,
            phone: header.customer_phone,
            address: header.customer_address,
            termsDays: header.customer_terms_days,
            taxExempt: header.customer_tax_exempt,
          },
          lines: linesRes.rows.map((l) => ({
            id: l.id,
            description: l.description,
            quantity: l.quantity,
            unitPrice: l.unit_price,
            amount: l.amount,
            incomeAccountId: l.income_account_id,
            incomeAccountName: l.income_account_name,
            taxable: l.taxable,
            itemId: l.item_id,
            itemName: l.item_name,
            itemKind: l.item_kind,
          })),
          applications: appsRes.rows.map((a) => ({
            id: a.id,
            paymentId: a.payment_id,
            amount: a.amount,
            paymentDate: a.payment_date,
            paymentStatus: a.payment_status,
          })),
          subtotal: totals.subtotal,
          taxableSubtotal: totals.taxableSubtotal,
          taxAmount: totals.taxAmount,
          taxRate: totals.taxRate,
          total: totals.total,
          applied: openApplied.applied,
          open: openApplied.open,
        };
      });

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

  // -------------------------------------------------------------------------
  // GET /api/c/invoices/:id/print
  // -------------------------------------------------------------------------
  fastify.get("/api/c/invoices/:id/print", 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 invoiceId = parsedParams.data.id;

    try {
      const result = await db.withCompany(companyId, async (client) => {
        const companyRes = await client.query<{ name: string }>(
          "SELECT name FROM companies WHERE id = $1", [companyId],
        );
        const companyName = companyRes.rows[0]?.name ?? null;

        const headerRes = await client.query<{
          id: string; invoice_no: string | null; invoice_date: string; due_date: string;
          memo: string | null; document_only: boolean;
          customer_name: string; customer_email: string | null; customer_phone: string | null;
          customer_address: string | null; customer_terms_days: number;
        }>(
          `SELECT i.id, i.invoice_no, i.invoice_date::text AS invoice_date, i.due_date::text AS due_date,
                  i.memo, i.document_only,
                  c.name AS customer_name, c.email AS customer_email, c.phone AS customer_phone,
                  c.address AS customer_address, c.terms_days AS customer_terms_days
             FROM invoices i
             JOIN customers c ON c.company_id = i.company_id AND c.id = i.customer_id
            WHERE i.company_id = $1 AND i.id = $2`,
          [companyId, invoiceId],
        );
        const header = headerRes.rows[0];
        if (!header) return { found: false as const };

        const linesRes = await client.query<{
          description: string | null; quantity: string; unit_price: string; amount: string;
          taxable: boolean;
        }>(
          `SELECT description, quantity, unit_price, amount, taxable
             FROM invoice_lines WHERE company_id = $1 AND invoice_id = $2 ORDER BY id`,
          [companyId, invoiceId],
        );

        // Same carry-over fix as the detail route: total is the GRAND total
        // (lines + tax) and applied = grand total − open, so an unpaid taxed
        // invoice prints applied 0, not −tax.
        const totals = await invoiceTotals(client, companyId, invoiceId);
        const openAppliedRes = await client.query<{ open: string; applied: string }>(
          `SELECT invoice_open_balance($1, $2) AS open,
                  ((SELECT coalesce(sum(l.amount), 0) FROM invoice_lines l
                     WHERE l.company_id = $1 AND l.invoice_id = $2)
                   + (SELECT tax_amount FROM invoices WHERE company_id = $1 AND id = $2)
                   - invoice_open_balance($1, $2))::numeric(19,4) AS applied`,
          [companyId, invoiceId],
        );
        const openApplied = openAppliedRes.rows[0]!;

        return {
          found: true as const,
          company: { name: companyName },
          invoice: {
            id: header.id,
            no: header.invoice_no,
            invoiceDate: header.invoice_date,
            dueDate: header.due_date,
            memo: header.memo,
            documentOnly: header.document_only,
          },
          customer: {
            name: header.customer_name,
            email: header.customer_email,
            phone: header.customer_phone,
            address: header.customer_address,
          },
          lines: linesRes.rows.map((l) => ({
            description: l.description, quantity: l.quantity, unitPrice: l.unit_price, amount: l.amount,
            taxable: l.taxable,
          })),
          subtotal: totals.subtotal,
          taxableSubtotal: totals.taxableSubtotal,
          taxAmount: totals.taxAmount,
          taxRate: totals.taxRate,
          total: totals.total,
          applied: openApplied.applied,
          balanceDue: openApplied.open,
          termsDays: header.customer_terms_days,
        };
      });

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