// Maps thrown Postgres errors to friendly English ({status, message}) per
// CLAUDE.md §9 ("Errors in English") and §12 ("Prefer database-level
// enforcement... application code may add friendlier errors on top, but
// must never be the only guard"). Every message here corresponds to a
// specific RAISE EXCEPTION / CHECK / UNIQUE constraint in db/migrations —
// see the comment above each branch for its source.
//
// mapPgError is a pure function (no I/O) so it's directly unit-testable
// with constructed error-shaped objects; the caller is responsible for
// logging the original error when it falls through to the generic 500.

export interface MappedError {
  status: number;
  message: string;
}

interface PgErrorLike {
  code?: string;
  message?: string;
  constraint?: string;
  table?: string;
  detail?: string;
}

// Default SQLSTATE for a plpgsql `RAISE EXCEPTION` with no explicit code.
const RAISE_EXCEPTION = "P0001";
const UNIQUE_VIOLATION = "23505";
const CHECK_VIOLATION = "23514";
const NOT_NULL_VIOLATION = "23502";
const FOREIGN_KEY_VIOLATION = "23503";
const INSUFFICIENT_PRIVILEGE = "42501";
const NUMERIC_VALUE_OUT_OF_RANGE = "22003";

// db/migrations/0003_import_layer.sql: UNIQUE (company_id, file_sha256) on
// import_batches — "re-importing an identical file is rejected" (CLAUDE.md §8).
const DUPLICATE_IMPORT_FILE_CONSTRAINT = "import_batches_company_id_file_sha256_key";
// db/migrations/0002_ledger_core.sql: UNIQUE (company_id, source, external_ref)
// on journal_entries — "re-importing the same external_ref under a different
// filename is also rejected" (CLAUDE.md §11).
const DUPLICATE_EXTERNAL_REF_CONSTRAINT = "journal_entries_company_id_source_external_ref_key";
// db/migrations/0005_ar.sql: UNIQUE (company_id, name) on customers.
const DUPLICATE_CUSTOMER_NAME_CONSTRAINT = "customers_company_id_name_key";
// db/migrations/0009_ap.sql: UNIQUE (company_id, name) on vendors.
const DUPLICATE_VENDOR_NAME_CONSTRAINT = "vendors_company_id_name_key";
// db/migrations/0009_ap.sql: UNIQUE INDEX bills_vendor_ref_no_unique ON
// bills (company_id, vendor_id, ref_no) WHERE ref_no IS NOT NULL.
const DUPLICATE_BILL_REF_NO_CONSTRAINT = "bills_vendor_ref_no_unique";
// db/migrations/0009_ap.sql: UNIQUE INDEX bill_payments_check_no_unique ON
// bill_payments (company_id, money_account_id, check_no) WHERE check_no IS
// NOT NULL — a check number is a number line per (company, money account),
// never reused, including by a voided check.
const DUPLICATE_CHECK_NO_CONSTRAINT = "bill_payments_check_no_unique";
// db/migrations/0011_bank_rec.sql: UNIQUE (company_id, money_account_id,
// file_sha256) on bank_statements (plain table-level UNIQUE, so Postgres's
// default <table>_<col>..._key naming applies, same as
// DUPLICATE_CUSTOMER_NAME_CONSTRAINT above) — "identical re-import of the
// same file against the same account is rejected outright" (CLAUDE.md §8's
// ADP idempotency lesson, applied here too).
const DUPLICATE_BANK_STATEMENT_FILE_CONSTRAINT =
  "bank_statements_company_id_money_account_id_file_sha256_key";
// db/migrations/0015_sales_tax.sql: UNIQUE (company_id, name) on tax_rates.
// server/src/salesTax.ts's own mapDuplicateRateName() is the one that
// actually names the rate (using the value the caller submitted — DETAIL is
// suppressed for the non-owner `app` role, same as the ref_no/check_no cases
// above); this branch stays as a defense-in-depth fallback.
const DUPLICATE_TAX_RATE_NAME_CONSTRAINT = "tax_rates_company_id_name_key";
// db/migrations/0015_sales_tax.sql: CHECK (tax_rate_id IS NOT NULL OR
// tax_amount = 0) on invoices — consistency at rest; the API always writes
// (rate, amount) as a coherent pair, so this firing means a bug upstream.
const TAX_AMOUNT_REQUIRES_RATE_CONSTRAINT = "invoices_tax_amount_requires_rate";
// db/migrations/0002_ledger_core.sql: UNIQUE (company_id, code) on accounts
// (plain table-level UNIQUE, default naming). accounts.ts pre-checks
// duplicate NAMES itself (name has no unique constraint); the code, being
// schema-unique, maps here.
const DUPLICATE_ACCOUNT_CODE_CONSTRAINT = "accounts_company_id_code_key";
// db/migrations/0016_account_subtypes.sql: the type/subtype CHECK matrix.
// accounts.ts pre-validates in English; this is the DB's own backstop.
const ACCOUNT_SUBTYPE_MATRIX_CONSTRAINT = "accounts_subtype_valid_for_type";
// db/migrations/0018_inventory.sql: UNIQUE (company_id, name) on items (plain
// table-level UNIQUE, so Postgres's default <table>_<cols>_key naming applies)
// and the partial UNIQUE INDEX items_sku_unique on (company_id, sku).
const DUPLICATE_ITEM_NAME_CONSTRAINT = "items_company_id_name_key";
const DUPLICATE_ITEM_SKU_CONSTRAINT = "items_sku_unique";
// db/migrations/0018_inventory.sql: UNIQUE (company_id, po_no) on
// purchase_orders. Only issue_purchase_order() ever assigns a number, under
// the counter row's own lock, so this firing means two allocations raced past
// that lock — defense in depth.
const DUPLICATE_PO_NO_CONSTRAINT = "purchase_orders_company_id_po_no_key";
// db/migrations/0018_inventory.sql: the two shape CHECKs on items. The API
// restates both in English before it ever gets here (server/src/items.ts's
// resolveItemAccounts); these are the database's own backstop.
const ITEM_INVENTORY_ACCOUNT_CONSTRAINT = "items_inventory_account_iff_inventory";
const ITEM_COGS_ACCOUNT_CONSTRAINT = "items_cogs_account_required";

// Universal import (server/src/universalImport.ts) — not Postgres errors,
// but kept here so every user-facing English string of the import pipeline
// lives in one reviewable place. UPLOAD_EXPIRED covers both an unknown
// uploadId and one that belonged to a different company (no distinction is
// leaked); the traversal-shaped/bad-format uploadId case is a plain 400 at
// the route's own validation, before any lookup happens.
export const UPLOAD_EXPIRED: MappedError = {
  status: 404,
  message: "That upload has expired — upload the file again.",
};
// Fired by the universal journal stage route when every year of the file is
// already loaded AND none of them is in a re-validatable state (all posted/
// rolled back) — i.e. re-staging this exact file has nothing left to do.
// (A re-upload while validation errors are still being fixed is NOT this
// case: those years re-validate, which is the QB fix-loop behavior.)
export const UNIVERSAL_FILE_ALREADY_IMPORTED: MappedError = {
  status: 409,
  message: "This exact file was already imported.",
};

const GENERIC_NOT_ALLOWED: MappedError = {
  status: 403,
  message: "You don't have permission to do that.",
};

const GENERIC_SERVER_ERROR: MappedError = {
  status: 500,
  message: "Something went wrong on our end. Nothing was saved.",
};

/**
 * Maps a thrown Postgres error (from `pg`, shaped like { code, message,
 * constraint, table }) to a friendly English { status, message }. Falls
 * through to a generic 500 for anything not explicitly recognized — the
 * caller should log the original error in that case, never just this
 * function's return value, so unmapped failures stay diagnosable.
 */
export function mapPgError(err: unknown): MappedError {
  const e = (err ?? {}) as PgErrorLike;
  const code = e.code;
  const message = e.message ?? "";

  if (code === RAISE_EXCEPTION) {
    const fromRaise = mapRaiseException(message);
    if (fromRaise) return fromRaise;
  }

  if (code === UNIQUE_VIOLATION) {
    if (e.constraint === DUPLICATE_IMPORT_FILE_CONSTRAINT) {
      return {
        status: 409,
        message: "This file has already been imported — re-importing an identical file is skipped.",
      };
    }
    if (e.constraint === DUPLICATE_EXTERNAL_REF_CONSTRAINT) {
      return {
        status: 409,
        message: "This transaction has already been imported (it matches one already on file, even under a different filename).",
      };
    }
    if (e.constraint === DUPLICATE_CUSTOMER_NAME_CONSTRAINT) {
      return { status: 409, message: "A customer with that name already exists." };
    }
    if (e.constraint === DUPLICATE_VENDOR_NAME_CONSTRAINT) {
      return { status: 409, message: "A vendor with that name already exists." };
    }
    if (e.constraint === DUPLICATE_BILL_REF_NO_CONSTRAINT) {
      // e.detail would look like "Key (company_id, vendor_id, ref_no)=
      // (<uuid>, 3, INV-100) already exists." if present — pulling the
      // ref_no (the tuple's last element) out gives a concrete, specific
      // message rather than a generic one. In practice, though, Postgres
      // suppresses DETAIL for a non-owner, RLS-subject role like `app` even
      // when the row is this company's own and fully visible to it under RLS
      // (verified empirically) — so this almost always falls through to the
      // generic message below in production. server/src/bills.ts's own
      // mapDuplicateRefNo() is the one that actually names the ref_no, using
      // the value the caller submitted rather than trying to recover it from
      // the DB error — this branch stays as a defense-in-depth fallback for
      // any other caller of mapPgError that hits this constraint.
      const refNo = /\(company_id, vendor_id, ref_no\)=\([^,]+,\s*[^,]+,\s*(.+)\) already exists/
        .exec(e.detail ?? "")?.[1];
      return {
        status: 409,
        message: 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.",
      };
    }
    if (e.constraint === DUPLICATE_CHECK_NO_CONSTRAINT) {
      // Same rationale as DUPLICATE_BILL_REF_NO_CONSTRAINT just above:
      // server/src/billPayments.ts's own mapDuplicateCheckNo() is the one
      // that actually names the check number, using the value the caller
      // submitted (or, in checks.ts's print-run race path, no number at all
      // — there's nothing to name when the collision came from an
      // automatically-assigned number) rather than trying to recover it from
      // the DB error's DETAIL (suppressed for the non-owner `app` role, same
      // as the ref_no case). This branch stays as a defense-in-depth
      // fallback for any other caller of mapPgError that hits this
      // constraint without going through that helper.
      return {
        status: 409,
        message: "That check number has already been used on this account — pick another number or update the next check number in Settings.",
      };
    }
    if (e.constraint === DUPLICATE_BANK_STATEMENT_FILE_CONSTRAINT) {
      return {
        status: 409,
        message: "This statement file has already been imported for this account — re-importing an identical file is skipped.",
      };
    }
    if (e.constraint === DUPLICATE_TAX_RATE_NAME_CONSTRAINT) {
      return { status: 409, message: "You already have a tax rate with that name." };
    }
    if (e.constraint === DUPLICATE_ACCOUNT_CODE_CONSTRAINT) {
      return { status: 409, message: "You already have an account using that code — pick a different one." };
    }
    if (e.constraint === DUPLICATE_ITEM_NAME_CONSTRAINT) {
      return { status: 409, message: "You already have an item with that name." };
    }
    if (e.constraint === DUPLICATE_ITEM_SKU_CONSTRAINT) {
      return { status: 409, message: "You already have an item using that SKU — pick a different one." };
    }
    if (e.constraint === DUPLICATE_PO_NO_CONSTRAINT) {
      return { status: 409, message: "That purchase order number is already in use." };
    }
    return { status: 409, message: "That already exists — nothing was changed." };
  }

  if (code === CHECK_VIOLATION || code === NOT_NULL_VIOLATION) {
    if (e.constraint === ITEM_INVENTORY_ACCOUNT_CONSTRAINT) {
      return {
        status: 422,
        message: "Only an item you stock has an inventory account — switch this item to inventory, or leave that account blank.",
      };
    }
    if (e.constraint === ITEM_COGS_ACCOUNT_CONSTRAINT) {
      return {
        status: 422,
        message: "Pick the account that records what this item costs you (an expense account, usually Cost of Goods Sold).",
      };
    }
    if (e.constraint === TAX_AMOUNT_REQUIRES_RATE_CONSTRAINT) {
      return {
        status: 422,
        message: "An invoice can't carry sales tax without a tax rate, so nothing was saved.",
      };
    }
    if (e.constraint === ACCOUNT_SUBTYPE_MATRIX_CONSTRAINT) {
      return {
        status: 422,
        message: "That grouping doesn't fit this account's type, so nothing was saved.",
      };
    }
    return {
      status: 422,
      message: "That doesn't fit the rules for this kind of record, so nothing was saved.",
    };
  }

  if (code === FOREIGN_KEY_VIOLATION) {
    return {
      status: 422,
      message: "That refers to something that doesn't exist (or belongs to a different company), so nothing was saved.",
    };
  }

  // Backstop for any number that overflows a NUMERIC column's precision that
  // slips past application-level bounds (e.g. server/src/invoices.ts's
  // per-field digit caps and computed-line-amount check) — should rarely
  // fire in practice, but must never surface as a raw 500 if it does.
  if (code === NUMERIC_VALUE_OUT_OF_RANGE) {
    return { status: 422, message: "That number is too large to store." };
  }

  if (code === INSUFFICIENT_PRIVILEGE || /row-level security|permission denied/i.test(message)) {
    return GENERIC_NOT_ALLOWED;
  }

  return GENERIC_SERVER_ERROR;
}

function mapRaiseException(message: string): MappedError | null {
  // db/migrations/0002_ledger_core.sql: assert_entry_balanced()
  if (/must have at least two lines/.test(message)) {
    return {
      status: 422,
      message: "An entry needs at least two lines (where the money comes from and where it goes) before it can be saved.",
    };
  }
  if (/is out of balance by/.test(message)) {
    const amount = /is out of balance by (-?[0-9.]+)/.exec(message)?.[1];
    return {
      status: 422,
      message: amount
        ? `This doesn't add up — it's off by ${formatAmount(amount)}.`
        : "This doesn't add up — the amounts in and out don't match.",
    };
  }

  // db/migrations/0011_bank_rec.sql: assert_not_reconciled(), called from both
  // post_entry() and reverse_entry_internal() — refuses anything that would
  // change a balance already tied to a bank statement. Already good English
  // (the migration's own header notes this is "the one RAISE text... the
  // spec quotes verbatim") — passed through unchanged, just given a status.
  if (/reconciled through .* or undo the reconciliation\.$/.test(message)) {
    return { status: 409, message };
  }

  // db/migrations/0002_ledger_core.sql: post_entry()
  if (/cannot post into locked period/.test(message)) {
    // Message shape: "cannot post into locked period (locked through 2023-12-31)"
    // — capture up to the closing paren, not \S+, which would swallow it.
    const lockedThrough = /locked through ([^)]+)/.exec(message)?.[1];
    return {
      status: 409,
      message: lockedThrough
        ? `That year is locked (through ${lockedThrough}); record it in the current year or unlock the period first.`
        : "That year is locked; record it in the current year or unlock the period first.",
    };
  }
  if (/is already posted/.test(message)) {
    return { status: 409, message: "This entry has already been posted." };
  }

  // db/migrations/0002_ledger_core.sql: forbid_posted_entry_change() /
  // forbid_posted_line_change() — §3 invariant 2, posted entries are immutable.
  if (/posted entries are immutable/.test(message)
    || /lines of a posted entry are immutable/.test(message)) {
    return {
      status: 409,
      message: "Posted entries can't be changed or deleted — record a correcting (reversing) entry instead.",
    };
  }

  // db/migrations/0002_ledger_core.sql: reverse_entry()
  if (/one leg of an intercompany posting/.test(message)) {
    return {
      status: 409,
      message: "This is one side of a transaction between two companies and can't be reversed by itself.",
    };
  }

  // db/migrations/0018_inventory.sql: reverse_entry()'s inventory guard.
  // POST /api/c/entries/:id/reverse can be pointed at an entry that moved
  // stock; reversing the money alone would leave the quantity on hand
  // untouched and the books permanently disagreeing with the shelf, so the
  // database refuses. Without this mapping that refusal would surface as a
  // bare 500.
  // Wording note: as of Task 2 the bill void path EXISTS (void_bill writes the
  // compensating movements itself — db/migrations/0020_inventory_receiving_fixes.sql),
  // so this now names it. It is only reachable from POST /api/c/entries/:id/reverse,
  // because void_bill no longer routes a movement-bearing entry through
  // reverse_entry() at all — a user who clicked "void this bill" can no longer
  // be told about "this entry". Invoices and builds join the list in Task 3;
  // the message must never point at a button that isn't on the screen yet.
  if (/moved stock, so reversing it on its own/.test(message)) {
    return {
      status: 409,
      message: "This entry moved stock, so reversing it on its own would leave the quantity on hand wrong. Void the document it came from, or record an inventory adjustment instead.",
    };
  }

  // db/migrations/0018_inventory.sql: check_movement_entry_posted()'s
  // per-account amount/account assertion. The commonest way to hit this is a
  // document line pointed straight at an inventory account instead of at an
  // item, so the money moves and the quantity does not.
  if (/of stock through inventory account \d+, but posts/.test(message)) {
    return {
      status: 422,
      message: "The stock and the money on this transaction don't match. A line can't post to an inventory account directly — pick the item instead, so the quantity and the value move together.",
    };
  }

  // db/migrations/0018_inventory.sql: the one-transaction rule.
  if (/already has stock movements from an earlier transaction/.test(message)) {
    return {
      status: 409,
      message: "This transaction has already been recorded. Record a new one instead of adding to it.",
    };
  }

  // db/migrations/0018_inventory.sql: record_adjustment()
  if (/say why the quantity is changing/.test(message)) {
    return {
      status: 422,
      message: "Say why the quantity is changing — an adjustment without a reason is impossible to audit later.",
    };
  }
  // The RAISE text already names the offending account and says what to do
  // instead, so it is surfaced as-is rather than flattened into something
  // vaguer. (Both the "this item's own" and "another item's" variants.)
  if (/is (this item's own|another item's) inventory account/.test(message)) {
    return { status: 422, message };
  }
  if (/an inventory adjustment posts to an expense, income or equity account/.test(message)) {
    return { status: 422, message };
  }
  // record_adjustment()'s zero-value refusal (0018:901). §3.1 forbids a
  // zero-amount journal line and every movement must ride on a posted entry, so
  // an adjustment to stock carrying no value at all has nowhere to go (0018's
  // known limit 8b).
  //
  // This branch was MISSING until Inventory Task 5's review: the only matching
  // pattern was mapInventoryRaiseException's `^the parts for '.+' have no value
  // on hand` — post_build()'s TWIN of this message, anchored at the start, so
  // the adjustment wording could never satisfy it. The refusal fell through to
  // the generic 500 ("Something went wrong on our end. Nothing was saved."),
  // which is both wrong about whose fault it is and useless about what to do.
  // Reachable whenever stock was received at zero cost: the API's own
  // unitCostSchema allows "0", and a stale client snapshot can send an
  // adjustment against an item whose value has since gone to zero.
  if (/has no value on hand, so this adjustment would move no money at all/.test(message)) {
    return { status: 422, message };
  }

  // db/migrations/0018_inventory.sql: negative stock (record_movement).
  // Already plain English with the item name and both numbers in it.
  if (/^You only have .* on hand — this would use /.test(message)) {
    return { status: 422, message };
  }

  // db/migrations/0002_ledger_core.sql: reverse_entry_internal()
  // ANCHORED (and it matters): 0021's reverse_movement() raises 'stock movement
  // % has already been reversed', which an unanchored /has already been
  // reversed/ swallowed here — several hundred lines before
  // mapInventoryRaiseException ever ran — and answered with the word "entry".
  // The status was right and nothing broke, which is exactly why it survived a
  // review; the vocabulary was wrong. tests/server/errors.test.ts now pins both.
  if (/^entry \d+ has already been reversed$/.test(message)) {
    return { status: 409, message: "This entry has already been reversed." };
  }
  if (/reversal date cannot precede the entry it reverses/.test(message)) {
    return {
      status: 422,
      message: "The reversal date can't be earlier than the entry it's reversing.",
    };
  }
  if (/only posted entries can be reversed/.test(message)) {
    return { status: 409, message: "Only posted entries can be reversed." };
  }

  // NOTE: "unmapped account" is never thrown as a PG exception — it's a
  // per-row status/message written into staging_journal by validate_batch()
  // (db/migrations/0003_import_layer.sql). That reporting flows through the
  // batch report (tools/lib/journal-load.ts's YearResult.errorGroups), not
  // through mapPgError.

  const inventory = mapInventoryRaiseException(message);
  if (inventory) return inventory;

  // Sales tax runs BEFORE the AR mapper on purpose: issue_invoice()'s tax
  // RAISEs start with "invoice N ..." shapes that must never be swallowed by
  // mapArRaiseException's broader invoice patterns.
  const tax = mapSalesTaxRaiseException(message);
  if (tax) return tax;

  const ar = mapArRaiseException(message);
  if (ar) return ar;

  const ap = mapApRaiseException(message);
  if (ap) return ap;

  const bank = mapBankRaiseException(message);
  if (bank) return bank;

  return null;
}

// ---------------------------------------------------------------------------
// Inventory — items, stock movements, purchase orders and receiving.
// db/migrations/0018_inventory.sql (Task 1) and
// db/migrations/0019_inventory_receiving.sql (Task 2 of
// docs/superpowers/plans/2026-08-03-inventory.md). Every branch below
// corresponds to a specific RAISE EXCEPTION in one of those files' functions or
// triggers. A few of those RAISEs are already exactly the sentence a user
// should read (they name the item, the account, or the quantity), so they are
// passed through verbatim with a status attached rather than flattened into
// something vaguer — the same convention ensure_ar_account()/
// ensure_ap_account()'s messages already follow above.
//
// Ordering note: this runs BEFORE mapArRaiseException/mapApRaiseException.
// Every pattern here is anchored or names "purchase order"/"item"/"stock"
// explicitly, so none of them can swallow an invoice or bill message.
// ---------------------------------------------------------------------------

function mapInventoryRaiseException(message: string): MappedError | null {
  // ---- items -------------------------------------------------------------

  // items_frozen_once_moved() (0018): kind and inventory account are history
  // once the item has moved — changing either would break the ledger tie
  // permanently, and movements can never be edited to repair it.
  if (/already has stock history — its item type and inventory account cannot be changed/.test(message)) {
    return { status: 409, message };
  }
  // items_deletable_only_when_unused() (0018).
  if (/has stock history and cannot be deleted — mark it inactive instead/.test(message)) {
    return { status: 409, message };
  }
  // items_inventory_account_backed() (0021): an account cannot be adopted as an
  // item's inventory account while it holds money no stock is recorded behind
  // — the commonest cause being CLAUDE.md §7's imported history. The RAISE
  // already names the account, the amount, and the cure (point the item at a
  // NEW account and record opening quantities there), so it is passed through.
  if (/already holds \$.* that no stock is recorded behind/.test(message)
    || /short of the stock already recorded in it/.test(message)) {
    return { status: 422, message };
  }
  // reverse_movement() (0021).
  if (/^stock movement \d+ has already been reversed$/.test(message)) {
    return { status: 409, message: "That stock movement has already been reversed." };
  }
  // items_frozen_while_drafted() (0020): the kind of an item a DRAFT bill line
  // already names cannot change, because that line's account was chosen from
  // the kind and is frozen on the row. Already the sentence to show.
  if (/is on a bill that hasn't been entered yet, so its item type/.test(message)) {
    return { status: 409, message };
  }
  // ...and the sales half of the same clause (0023).
  if (/is on an invoice that hasn't been sent yet, so its item type/.test(message)) {
    return { status: 409, message };
  }
  // items_accounts_typed() (0018). server/src/items.ts restates all three rules
  // in English before it ever gets here; this is the database's own backstop.
  {
    const m = /^the (inventory|cost|income) account for '(.+)' must be (an? \w+) account/.exec(message);
    if (m) {
      return {
        status: 422,
        message: `The ${m[1]} account for "${m[2]}" has to be ${m[3]} account.`,
      };
    }
  }
  if (/^item \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that item." };
  }

  // ---- bills of materials (0018's assemblies_no_recursion) ---------------
  //
  // All four already name the items involved; they are given a status and a
  // closing full stop rather than being flattened into something vaguer. Task 3
  // is the first caller that can reach them (there was no BOM route before), so
  // without these they would have surfaced as bare 500s.
  if (/^only a stocked item can be built from parts — '.+' is not one$/.test(message)) {
    return { status: 422, message: `O${message.slice(1)}.` };
  }
  if (/^'.+' is not a stocked item, so it cannot be a part of '.+'$/.test(message)) {
    return { status: 422, message: `${message}.` };
  }
  if (/^'.+' cannot be a part of itself$/.test(message)) {
    return { status: 422, message: `${message}.` };
  }
  if (/would make the item a part of itself$/.test(message)) {
    return { status: 422, message: `${message}.` };
  }

  // ---- stock movements ---------------------------------------------------

  // forbid_movement_change() (0018): append-only forever, like posted entries.
  if (/^stock movements are a permanent record/.test(message)) {
    return {
      status: 409,
      message: "Stock movements can't be changed or deleted — record an inventory adjustment instead.",
    };
  }
  // record_movement() (0018): kind and cost guards.
  if (/^'.+' is not a stocked item, so it has no quantity on hand$/.test(message)) {
    return { status: 422, message };
  }
  {
    const m = /^receiving '(.+)' needs a unit cost$/.exec(message);
    if (m) {
      return { status: 422, message: `Enter what "${m[1]}" cost you — stock can't come in without a cost.` };
    }
  }
  if (/^a stock movement has to change the quantity by something$/.test(message)) {
    return { status: 422, message: "Enter a quantity — a stock movement has to change the quantity by something." };
  }
  if (/^a unit cost cannot be negative$/.test(message)
    || /^a unit cost has to be a real number$/.test(message)
    || /^a value has to be a real number$/.test(message)
    || /^stock coming in cannot be worth less than nothing$/.test(message)) {
    return { status: 422, message: "Enter a cost of zero or more." };
  }
  if (/^stock movement \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that stock movement." };
  }
  // record_movement() (0018/0020): the running average IS the cost of stock
  // going out, so a caller may not name one — nor supply both a unit cost and a
  // total value on the way in.
  if (/^the cost of stock going out is set by the running average/.test(message)) {
    return {
      status: 422,
      message: "Stock going out is valued at what it already cost you on average, so leave the cost blank.",
    };
  }
  if (/^a stock movement is costed either by its unit cost or by its total value/.test(message)) {
    return {
      status: 422,
      message: "Give either a unit cost or a total value for this stock, not both.",
    };
  }
  // reverse_movement() (0020): undoing a receipt whose stock has already been
  // sold or built into something would drive the quantity on hand negative.
  // Already names the item and both numbers, and says what to do instead.
  if (/^You can't undo this receipt — /.test(message)) {
    return { status: 409, message };
  }

  // ---- purchase orders ---------------------------------------------------

  if (/^purchase order \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that purchase order." };
  }
  if (/^purchase order line \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that purchase order line." };
  }
  // issue_purchase_order() (0018).
  {
    const m = /^purchase order \d+ is already (\w+)$/.exec(message);
    if (m) {
      return {
        status: 409,
        message: m[1] === "cancelled"
          ? "This purchase order has already been cancelled."
          : "This purchase order has already been sent to the vendor.",
      };
    }
  }
  if (/^purchase order \d+ has nothing on it$/.test(message)) {
    return { status: 422, message: "Add at least one line before you can send this purchase order." };
  }
  // purchase_orders_forward_only() (0018, extended in 0019).
  if (/^purchase order \d+ has been issued — cancel it instead of deleting it$/.test(message)) {
    return {
      status: 409,
      message: "This purchase order has already been sent to the vendor — cancel it instead of deleting it.",
    };
  }
  if (/^purchase order #\d+ keeps its number$/.test(message)) {
    return { status: 409, message: "A purchase order keeps the number it was sent under." };
  }
  {
    const m = /^a purchase order cannot go from (\w+) back to (\w+)$/.exec(message);
    if (m) {
      return { status: 409, message: `A purchase order can't go from ${m[1]} back to ${m[2]}.` };
    }
  }
  // The 0019 header freeze and purchase_order_lines_immutable_when_issued()
  // share this sentence, which is already the one to show.
  if (/has already been sent to the vendor — cancel it and create a new one instead of changing it/.test(message)) {
    return { status: 409, message: "This purchase order has already been sent to the vendor — cancel it and create a new one instead of changing it." };
  }
  // purchase_orders_born_draft() (0018). Unreachable through these routes
  // (every INSERT here lands in the born state); defense in depth.
  if (/^purchase orders are created as drafts; use issue_purchase_order\(\)/.test(message)) {
    return { status: 422, message: "Something went wrong creating that purchase order. Nothing was saved." };
  }
  // set_next_po_no() / set_next_build_no() (0018) — fast-forward only, mirroring
  // set_next_invoice_no()/set_next_check_no()'s messages above.
  {
    const m = /^next (purchase order|build) number must be greater than the highest used \((-?\d+)\)$/
      .exec(message);
    if (m) {
      return {
        status: 422,
        message: `The next ${m[1]} number must be greater than the highest one already used (${m[2]}).`,
      };
    }
  }

  // ---- receiving: bill lines that carry items (0019) ----------------------

  // bill_lines_item_consistent(): all four already name the item, the account
  // or the purchase order, so they are passed through as written.
  if (/^a line for '.+' has to go to that item's own inventory account/.test(message)) {
    return { status: 422, message };
  }
  if (/is an inventory account — pick the item you are buying instead/.test(message)) {
    return { status: 422, message };
  }
  // issue_bill() (0020): the line's item stopped being stocked between the
  // draft and the issue, so its frozen account no longer has stock behind it.
  if (/is not a stocked item any more, so this bill cannot put its cost into/.test(message)) {
    return { status: 409, message };
  }
  if (/^this line receives a different item than the purchase order line it is filling$/.test(message)) {
    return { status: 422, message: "This line receives a different item than the purchase order line it's filling." };
  }
  if (/^that purchase order has not been sent to the vendor yet/.test(message)) {
    return { status: 409, message: "That purchase order hasn't been sent to the vendor yet — send it before receiving against it." };
  }
  if (/^purchase order #\d+ was cancelled$/.test(message)) {
    const no = /#(\d+)/.exec(message)?.[1];
    return { status: 409, message: `Purchase order #${no} was cancelled — you can't receive against it.` };
  }
  if (/^this bill and purchase order #\d+ belong to different vendors$/.test(message)) {
    return { status: 422, message: "That bill and that purchase order belong to different vendors." };
  }

  // issue_bill() (0019): a document-only bill posts nothing, and stock can
  // never move without a posted entry behind it.
  if (/is recorded for tracking only, so it cannot bring stock in/.test(message)) {
    return {
      status: 422,
      message: "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.",
    };
  }
  // check_entry_stock_backed() (0022): the other half of the tie — an entry that
  // moves money into or out of an inventory account without moving the stock
  // behind it. The commonest way in is a transfer whose destination happens to
  // be an inventory account. The RAISE already names the account and both cures.
  if (/directly — it is an inventory account/.test(message)) {
    return { status: 422, message };
  }

  // issue_bill() (0019, tightened in 0020): the line's own quantity, unit price
  // and amount don't multiply out. Already names the item and every number.
  if (/the quantity, the cost and the amount have to agree$/.test(message)) {
    return { status: 422, message };
  }

  // ---- selling stocked items (0023) --------------------------------------

  // issue_invoice(): a document-only invoice posts nothing, and stock can never
  // move without a posted entry behind it. The mirror of the bill rule above.
  if (/is recorded for tracking only, so it cannot move stock/.test(message)) {
    return {
      status: 422,
      message: "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.",
    };
  }

  // ---- builds (0023) ------------------------------------------------------

  // post_build(). The first three already name the item and say what to do.
  if (/has no parts list yet — say what it is made from before you build it$/.test(message)) {
    return { status: 422, message };
  }
  if (/^the parts for '.+' have no value on hand/.test(message)) {
    return { status: 422, message };
  }
  if (/^building .* works out to no '.+' at all/.test(message)) {
    return { status: 422, message };
  }
  if (/^enter how many you are building$/.test(message)) {
    return { status: 422, message: "Enter how many you're building." };
  }
  if (/^build \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that build." };
  }
  // reverse_build().
  if (/^build #\d+ has already been reversed$/.test(message)) {
    return { status: 409, message: "That build has already been undone." };
  }
  if (/^You can't undo build #\d+ — /.test(message)) {
    return { status: 409, message };
  }
  // assert_build_posting() / the builds_entry_matches constraint trigger: the
  // one place the structural tie is blind (a same-account build's wash nets to
  // zero on the account, so its AMOUNT is unconstrained by the per-account
  // check). Only reachable by writing a build and its entry by hand, so the
  // sentence explains the rule rather than the raw numbers.
  if (/a build's entry has to say exactly what the build was worth/.test(message)
    || /did not record what it was worth, so its journal entry cannot be checked/.test(message)) {
    return {
      status: 422,
      message: "The stock and the money on this build don't match — a build's journal entry has to say exactly what the build was worth, on the accounts the parts came out of and the finished item went into.",
    };
  }

  // ---- opening quantities (0023) ------------------------------------------

  if (/^say where these opening quantities came from/.test(message)) {
    return {
      status: 422,
      message: "Say where these opening quantities came from — a count nobody explained is impossible to audit later.",
    };
  }
  if (/^list at least one item and how many of it you have on hand$/.test(message)) {
    return { status: 422, message: "List at least one item and how many of it you have on hand." };
  }
  if (/^each item can only be counted once/.test(message)) {
    return { status: 422, message: "Each item can only be counted once." };
  }
  if (/^an opening count of '.+' has to be more than zero/.test(message)
    || /^enter what '.+' cost you — an opening count needs a cost/.test(message)
    // 0023's "that is some item's inventory account" and 0024's three money
    // refusals (a bank subtype, a customer-balances subtype, and any account
    // that has ever been reconciled) share this clause and are all already the
    // sentence to show, naming the account and the two sources that ARE right.
    || /so the opening value cannot come out of it/.test(message)
    || /^opening quantities come from Opening Balance Equity/.test(message)
    || /but these opening quantities come to \$/.test(message)) {
    return { status: 422, message };
  }
  // db/migrations/0025_opening_quantities_history_guard.sql: the double-count
  // refusal. Already names the risk, the account and the alternative, so it is
  // surfaced verbatim. 409, not 422: nothing about the request is malformed —
  // it conflicts with the state of the company's books, and the caller may
  // legitimately answer it (server/src/inventoryAdjustments.ts flags it as
  // answerable so the screen can offer that answer).
  if (/^This company brought in transaction history/.test(message)) {
    return { status: 409, message };
  }
  if (/^these opening quantities are worth nothing at all/.test(message)) {
    return {
      status: 422,
      message: "These opening quantities are worth nothing at all, so there's nothing to record.",
    };
  }
  if (/already has stock history, so this is not its opening count/.test(message)) {
    return { status: 409, message };
  }

  return null;
}

// ---------------------------------------------------------------------------
// Sales tax — db/migrations/0015_sales_tax.sql. Every branch below
// corresponds to a specific RAISE EXCEPTION in one of that migration's
// functions/triggers (enable_sales_tax, disable_sales_tax, issue_invoice's
// tax validation, tax_rates_rate_frozen_when_used, and
// company_settings_sales_tax_account_is_liability).
// ---------------------------------------------------------------------------

function mapSalesTaxRaiseException(message: string): MappedError | null {
  // enable_sales_tax(): ambiguous payable account — already good English
  // (mirrors ensure_ar_account/ensure_ap_account's passthroughs above).
  if (/More than one active account is named 'Sales Tax Payable'/.test(message)) {
    return { status: 422, message };
  }

  // enable_sales_tax(): the 2200-2299 code range is completely taken.
  if (/no free account code between 2200 and 2299/.test(message)) {
    return {
      status: 422,
      message: "We couldn't find a free account code for 'Sales Tax Payable' — create a liability account named 'Sales Tax Payable' yourself, then turn sales tax on again.",
    };
  }

  // disable_sales_tax(): a draft still carries a rate — clean up first.
  if (/^draft invoice \d+ still has sales tax on it/.test(message)) {
    const draftId = /^draft invoice (\d+)/.exec(message)?.[1];
    return {
      status: 409,
      message: draftId
        ? `Draft invoice ${draftId} still has sales tax on it — remove its tax rate (or issue or delete the draft) before turning sales tax off.`
        : "A draft invoice still has sales tax on it — remove its tax rate (or issue or delete the draft) before turning sales tax off.",
    };
  }

  // tax_rates_rate_frozen_when_used(): the percent is frozen once any issued
  // invoice references the rate. Anchored at BOTH ends (the rate NAME is
  // interpolated between them, and a name crafted to look like another tax
  // error's text must not cross-map — the inactive-rate branch below is
  // anchored the same way for the same reason; with both fully anchored,
  // neither message can satisfy the other's regex regardless of the name).
  if (/^tax rate '.*' has been used on issued invoices — deactivate it and create a new rate instead of changing the percent$/.test(message)) {
    return {
      status: 409,
      message: "This tax rate has been used on issued invoices, so its percent can't change — deactivate it and create a new rate instead.",
    };
  }

  // issue_invoice(): the company-level gate — sales tax must be ON to issue a
  // taxed invoice. (The API also refuses a taxRateId on a disabled company at
  // draft time; this is the DB's own backstop, per CLAUDE.md §12.)
  if (/sales tax is turned off for this company/.test(message)) {
    return {
      status: 409,
      message: "Sales tax is turned off for this company — turn it on in Settings before issuing a taxed invoice.",
    };
  }

  // issue_invoice(): tax consistency (also a table CHECK — see
  // TAX_AMOUNT_REQUIRES_RATE_CONSTRAINT above).
  if (/^invoice \d+ has a tax amount but no tax rate$/.test(message)) {
    return {
      status: 422,
      message: "An invoice can't carry sales tax without a tax rate, so nothing was saved.",
    };
  }

  // issue_invoice(): the rate must still be active at issue. Fully anchored —
  // see the frozen-percent branch's comment above.
  if (/^tax rate '.*' is inactive — reactivate it or pick another rate$/.test(message)) {
    return {
      status: 422,
      message: "That tax rate is inactive — reactivate it in Settings or pick another rate.",
    };
  }

  // issue_invoice(): rate not found (defense-in-depth; the composite FK makes
  // a cross-company or dangling reference impossible to store).
  if (/^tax rate \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that tax rate." };
  }

  // issue_invoice(): the recompute guard — the stored tax_amount doesn't
  // match the rate applied to the taxable lines. The API recomputes tax on
  // every draft mutation AND self-heals at issue, so this firing means a
  // server bug (which is why it maps to a 500, so the caller logs the raw
  // error), never a user mistake.
  if (/tax amount .* does not match the .* rate on its taxable lines/.test(message)) {
    return {
      status: 500,
      message: "The sales tax on this invoice didn't add up on our end — nothing was saved.",
    };
  }

  // issue_invoice(): tax present but no payable account configured.
  if (/no Sales Tax Payable account is configured/.test(message)) {
    return {
      status: 422,
      message: "No Sales Tax Payable account is set up — turn sales tax on in Settings first.",
    };
  }

  // company_settings_sales_tax_account_is_liability() trigger.
  if (/sales tax account must be a liability account/.test(message)) {
    return {
      status: 422,
      message: "The sales tax account has to be a liability account (money you owe), not any other kind.",
    };
  }

  return null;
}

// ---------------------------------------------------------------------------
// Accounts Receivable — db/migrations/0005_ar.sql, 0006_ar_fixes.sql, and
// 0008_ar_closeout.sql. Every branch below corresponds to a specific RAISE
// EXCEPTION in one of the AR functions/triggers there (issue_invoice,
// void_invoice, receive_payment, apply_credit, void_payment,
// set_next_invoice_no, ensure_ar_account, and the immutability triggers on
// invoices/invoice_lines/payments/payment_applications).
// ---------------------------------------------------------------------------

function mapArRaiseException(message: string): MappedError | null {
  // ensure_ar_account(): already good English — pass it through verbatim
  // rather than re-wording it, just picking a status.
  if (/No account named 'Accounts Receivable' exists/.test(message)
    || /More than one active account is named 'Accounts Receivable'/.test(message)) {
    return { status: 422, message };
  }

  // void_invoice(): refuses when any payment has been applied — unapply
  // (void the payments) first. Anchored to "invoice \d+" (not just the
  // shared "has payments applied — void the payments applied to it first"
  // tail) so this can never shadow void_bill()'s identically-shaped AP
  // RAISE below — mapArRaiseException runs before mapApRaiseException, so an
  // unanchored regex here would silently steal every bill's version of this
  // message too (regression: a bill with applied payments was reporting
  // "This invoice has payments applied..." instead of "This bill...").
  if (/invoice \d+ has payments applied — void the payments applied to it first/.test(message)) {
    return {
      status: 409,
      message: "This invoice has payments applied to it — void those payments first, then void this invoice.",
    };
  }

  // void_invoice(): only an 'open' invoice can be voided (not draft, not
  // already void).
  if (/only open invoices can be voided/.test(message)) {
    const status = /invoice \d+ is (\w+) — only open invoices can be voided/.exec(message)?.[1];
    if (status === "void") {
      return { status: 409, message: "This invoice has already been voided." };
    }
    return {
      status: 409,
      message: "Only an issued invoice can be voided — issue this one first.",
    };
  }

  // issue_invoice(): the invoice is already issued or already void.
  if (/invoice \d+ is already \w+/.test(message)) {
    const status = /invoice \d+ is already (\w+)/.exec(message)?.[1];
    if (status === "void") {
      return { status: 409, message: "This invoice has already been voided." };
    }
    return { status: 409, message: "This invoice has already been issued." };
  }

  // issue_invoice(): a draft with zero lines can't be sent to the books.
  if (/invoice \d+ has no lines/.test(message)) {
    return { status: 422, message: "Add at least one line before you can issue this invoice." };
  }

  // invoices_immutable_when_issued() / invoice_lines_immutable_when_issued():
  // §3 invariant 2's AR analogue — an issued invoice (and its lines) can't be
  // edited or deleted; the correction path is void_invoice(), not editing.
  if (/issued invoice \d+ is immutable/.test(message)
    || /lines of an issued invoice are immutable/.test(message)) {
    return {
      status: 409,
      message: "You can't edit an invoice once it's issued — void it instead, then create a corrected one.",
    };
  }

  // payments_immutable(): the only legal transition is active -> void.
  if (/payments are immutable; void payment/.test(message)) {
    return {
      status: 409,
      message: "A payment can't be edited or deleted once recorded — void it instead.",
    };
  }

  // void_payment(): refuses to void an already-void payment. Checked BEFORE
  // apply_credit()'s plain "payment % is void" below, since that pattern
  // would otherwise also match this longer "already void" message.
  if (/payment \d+ is already void/.test(message)) {
    return { status: 409, message: "This payment has already been voided." };
  }

  // apply_credit(): refuses to apply more credit from a payment that's been
  // voided — the credit no longer exists to apply.
  if (/payment \d+ is void/.test(message)) {
    return { status: 409, message: "That payment has been voided and can't be used." };
  }

  // payment_applications_immutable(): applications are append-only forever;
  // the correction path is voiding the payment (a reversal).
  if (/payment applications are immutable/.test(message)) {
    return {
      status: 409,
      message: "A payment's applications can't be changed — void the payment instead.",
    };
  }

  // check_payment_application(): deferred over-application guards.
  if (/payment \d+ is over-applied/.test(message)) {
    return {
      status: 422,
      message: "That's more than this payment covers — reduce the amount applied.",
    };
  }
  if (/invoice \d+ is over-applied/.test(message)) {
    return {
      status: 422,
      message: "That's more than this invoice's balance — reduce the amount applied.",
    };
  }
  if (/belong to different customers/.test(message)) {
    return {
      status: 422,
      message: "That payment and that invoice belong to different customers.",
    };
  }

  // receive_payment() / apply_credit(): the invoice targeted isn't open (not
  // found, draft, void, or already paid — this system has no "closed" state
  // beyond those).
  if (/only open invoices accept payment/.test(message)) {
    return { status: 422, message: "Only an issued, unpaid invoice can accept a payment." };
  }
  if (/does not belong to this customer/.test(message)) {
    return { status: 422, message: "That invoice doesn't belong to this customer." };
  }

  // Not-found variants surfaced by the AR functions themselves — routes
  // should generally pre-check and 404 before calling these, but this is the
  // defense-in-depth fallback (CLAUDE.md §12).
  if (/^customer \d+ not found/.test(message)) {
    return { status: 404, message: "We couldn't find that customer." };
  }
  if (/^invoice \d+ not found/.test(message)) {
    return { status: 404, message: "We couldn't find that invoice." };
  }
  if (/^payment \d+ not found/.test(message)) {
    return { status: 404, message: "We couldn't find that payment." };
  }

  // invoice_lines_income_account_only() trigger: defense-in-depth for a line
  // whose account isn't type 'income' (routes validate this themselves first).
  if (/invoice lines must post to an income account/.test(message)) {
    return {
      status: 422,
      message: "Pick an income category for each line — not a money account.",
    };
  }

  // db/migrations/0008_ar_closeout.sql: set_next_invoice_no() — fast-forward
  // only, never rewind past a number already used.
  if (/next invoice number must be greater than the highest used/.test(message)) {
    const highest = /highest used \((-?\d+)\)/.exec(message)?.[1];
    return {
      status: 422,
      message: highest
        ? `The next invoice number must be greater than the highest one already used (${highest}).`
        : "The next invoice number must be greater than the highest one already used.",
    };
  }

  return null;
}

// ---------------------------------------------------------------------------
// Accounts Payable — db/migrations/0009_ap.sql. Deliberately a mechanical
// vendor/bill mirror of mapArRaiseException above (Task 2 of
// docs/superpowers/plans/2026-08-01-ap.md): every branch here corresponds to
// a specific RAISE EXCEPTION in one of the AP functions/triggers there
// (issue_bill, void_bill, ensure_ap_account, bill_lines_category_not_ap, and
// the immutability triggers on bills/bill_lines). pay_bills/void_bill_payment/
// apply_vendor_credit/assign_check_numbers/set_next_check_no are Task 3's
// concern (billPayments.ts/checks.ts) and get their own mappings then.
// ---------------------------------------------------------------------------

function mapApRaiseException(message: string): MappedError | null {
  // ensure_ap_account(): already good English — pass it through verbatim
  // rather than re-wording it, just picking a status.
  if (/No account named 'Accounts Payable' exists/.test(message)
    || /More than one active account is named 'Accounts Payable'/.test(message)) {
    return { status: 422, message };
  }

  // bill_lines_category_not_ap(): a bill line can't post to the company's
  // own A/P account (expense or any other non-A/P account is fine).
  if (/bill lines cannot post to the Accounts Payable account itself/.test(message)) {
    return {
      status: 422,
      message: "Pick a category account for each line — not the Accounts Payable account itself.",
    };
  }

  // void_bill(): refuses when any payment has been applied — unapply (void
  // the payments) first. Anchored to "bill \d+" (see the matching comment on
  // mapArRaiseException's invoice version above) so this can't shadow — or
  // be shadowed by — void_invoice()'s identically-shaped AR RAISE.
  if (/bill \d+ has payments applied — void the payments applied to it first/.test(message)) {
    return {
      status: 409,
      message: "This bill has payments applied to it — void those payments first, then void this bill.",
    };
  }

  // void_bill(): only an 'open' bill can be voided (not draft, not already void).
  if (/only open bills can be voided/.test(message)) {
    const status = /bill \d+ is (\w+) — only open bills can be voided/.exec(message)?.[1];
    if (status === "void") {
      return { status: 409, message: "This bill has already been voided." };
    }
    return {
      status: 409,
      message: "Only an issued bill can be voided — issue this one first.",
    };
  }

  // issue_bill(): the bill is already issued or already void.
  if (/bill \d+ is already \w+/.test(message)) {
    const status = /bill \d+ is already (\w+)/.exec(message)?.[1];
    if (status === "void") {
      return { status: 409, message: "This bill has already been voided." };
    }
    return { status: 409, message: "This bill has already been issued." };
  }

  // issue_bill(): a draft with zero lines can't be sent to the books.
  if (/bill \d+ has no lines/.test(message)) {
    return { status: 422, message: "Add at least one line before you can issue this bill." };
  }

  // bills_immutable_when_issued() / bill_lines_immutable_when_issued():
  // §3 invariant 2's AP analogue — an issued bill (and its lines) can't be
  // edited or deleted; the correction path is void_bill(), not editing.
  if (/issued bill \d+ is immutable/.test(message)
    || /lines of an issued bill are immutable/.test(message)) {
    return {
      status: 409,
      message: "You can't edit a bill once it's issued — void it instead, then create a corrected one.",
    };
  }

  // Not-found variants surfaced by the AP functions themselves — routes
  // should generally pre-check and 404 before calling these, but this is the
  // defense-in-depth fallback (CLAUDE.md §12).
  if (/^vendor \d+ not found/.test(message)) {
    return { status: 404, message: "We couldn't find that vendor." };
  }
  if (/^bill \d+ not found/.test(message)) {
    return { status: 404, message: "We couldn't find that bill." };
  }

  const apPay = mapApPaymentsRaiseException(message);
  if (apPay) return apPay;

  return null;
}

// ---------------------------------------------------------------------------
// Accounts Payable — payments + check printing — db/migrations/0009_ap.sql's
// pay_bills, apply_vendor_credit, void_bill_payment, set_next_check_no,
// assign_check_numbers, and check_bill_payment_application's over-application
// guards. Task 3 of docs/superpowers/plans/2026-08-01-ap.md (mirrors the AR
// receive_payment/apply_credit/void_payment family in mapArRaiseException
// above; only the genuinely new check-printing messages are novel here).
// ---------------------------------------------------------------------------

function mapApPaymentsRaiseException(message: string): MappedError | null {
  // pay_bills(): the bill targeted by an application isn't open (not found,
  // draft, void, or already paid).
  if (/only open bills accept payment/.test(message)) {
    return { status: 422, message: "Only an issued, unpaid bill can accept a payment." };
  }
  if (/does not belong to this vendor/.test(message)) {
    return { status: 422, message: "That bill doesn't belong to this vendor." };
  }

  // check_bill_payment_application(): deferred over-application guards —
  // same shape as check_payment_application()'s AR versions above, but
  // "payment \d+ is over-applied" is intentionally already shared (identical
  // wording) rather than duplicated here.
  if (/bill \d+ is over-applied/.test(message)) {
    return {
      status: 422,
      message: "That's more than this bill's balance — reduce the amount applied.",
    };
  }
  if (/belong to different vendors/.test(message)) {
    return {
      status: 422,
      message: "That payment and that bill belong to different vendors.",
    };
  }

  // set_next_check_no(): fast-forward only, never rewind past a number
  // already used on this account — mirrors set_next_invoice_no()'s AR message.
  if (/next check number must be greater than the highest used/.test(message)) {
    const highest = /highest used \((-?\d+)\)/.exec(message)?.[1];
    return {
      status: 422,
      message: highest
        ? `The next check number must be greater than the highest one already used on this account (${highest}).`
        : "The next check number must be greater than the highest one already used on this account.",
    };
  }

  // assign_check_numbers(): no payments named at all (routes validate this
  // themselves first via zod's .min(1), so this is defense-in-depth only).
  if (/no payments selected for check printing/.test(message)) {
    return { status: 400, message: "Pick at least one payment to print a check for." };
  }

  // assign_check_numbers(): a named payment isn't (or is no longer) a check
  // payment queued to print on the requested account — e.g. it was already
  // printed or voided by a concurrent print run between the app's own
  // pre-check and this function's atomic per-payment lock+validate.
  if (/is not a check payment queued to print on this account/.test(message)) {
    return {
      status: 409,
      message: "That payment isn't waiting to be printed anymore — it may already have been printed or voided.",
    };
  }

  return null;
}

// ---------------------------------------------------------------------------
// Bank reconciliation — db/migrations/0011_bank_rec.sql (statement lines
// match/unmatch/exclude/restore/create-entry/reconcile/undo) and
// 0012_bank_rec_born_state.sql (born-state guards). Task 3 of
// docs/superpowers/plans/2026-08-01-bank-rec.md. Every branch below
// corresponds to a specific RAISE EXCEPTION in one of those functions or
// triggers; the assert_not_reconciled() passthrough (post_entry()/
// reverse_entry_internal()'s new guard) is handled earlier in
// mapRaiseException, not here, since it fires from functions that predate
// this migration. "statement line % not found" / "statement % not found"
// and "account % not found" are deliberately left unmapped here, same
// convention as mapArRaiseException/mapApRaiseException's own not-found
// variants and entries.ts's own comment on reverse_entry(): server/src/bank.ts
// pre-checks existence itself wherever it would otherwise matter, so these
// are defense-in-depth only, not expected to be hit by normal use — EXCEPT
// the two below that ARE mapped, because bank.ts intentionally lets the
// database be the sole judge of match/exclude/restore validity rather than
// duplicating the whole state machine as pre-checks.
// ---------------------------------------------------------------------------

function mapBankRaiseException(message: string): MappedError | null {
  // match_statement_line(): line not found (defense-in-depth; bank.ts
  // pre-checks this itself for a clean 404 on every route that calls it).
  if (/^statement line \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that statement line." };
  }
  // Shared by several functions: statement not found.
  if (/^statement \d+ not found$/.test(message)) {
    return { status: 404, message: "We couldn't find that statement." };
  }

  // match_statement_line(): line is already matched/created/excluded —
  // checked BEFORE the plainer "is already %" pattern below (create_entry_
  // from_line's own version, which has no "; unmatch it first" suffix), so
  // the more specific message always wins the match.
  if (/^statement line \d+ is already (\w+); unmatch it first$/.test(message)) {
    const status = /is already (\w+); unmatch it first/.exec(message)?.[1];
    return {
      status: 409,
      message: `This line is already ${status} — unmatch it first.`,
    };
  }

  // Shared by match_statement_line()/unmatch_statement_line()/
  // exclude_statement_line()/restore_statement_line(): the parent statement
  // is reconciled, so its lines are frozen (also bank_statement_lines_guard's
  // own trigger text, identical wording).
  if (/^statement \d+ is reconciled; its lines are frozen/.test(message)) {
    return {
      status: 409,
      message: "This statement has been reconciled — its lines are frozen. Undo the reconciliation first if you need to change something.",
    };
  }

  // match_statement_line(): the target entry isn't posted.
  if (/^entry \d+ is not posted$/.test(message)) {
    return { status: 422, message: "That entry hasn't been posted yet — only posted entries can be matched." };
  }

  // match_statement_line(): the entry doesn't carry exactly one line on this account.
  if (/must carry exactly one line on this account to be matched/.test(message)) {
    return {
      status: 422,
      message: "That entry doesn't have exactly one line on this account, so it can't be matched to this statement line.",
    };
  }

  // match_statement_line(): the entry's amount doesn't match the statement line's.
  if (/does not match the statement line amount/.test(message)) {
    return {
      status: 422,
      message: "That entry's amount doesn't match this statement line — pick a different entry or create a new one instead.",
    };
  }

  // match_statement_line(): the entry is already settled by a different line
  // (concurrency: two lines racing to match the same entry).
  if (/^entry \d+ is already settled by statement line \d+$/.test(message)) {
    return { status: 409, message: "That entry has already been matched to another statement line." };
  }

  // unmatch_statement_line(): the line isn't matched/created to begin with.
  if (/^statement line \d+ is not matched$/.test(message)) {
    return { status: 409, message: "This line isn't matched to anything yet." };
  }

  // exclude_statement_line(): a reason is required (bank.ts's own zod schema
  // already requires this; kept as defense-in-depth).
  if (/a reason is required to exclude a statement line/.test(message)) {
    return { status: 422, message: "Tell us why you're excluding this line." };
  }

  // exclude_statement_line(): the line is already matched/created — unmatch
  // it before excluding.
  if (/^statement line \d+ is (\w+) — unmatch it before excluding$/.test(message)) {
    const status = /is (\w+) — unmatch it before excluding/.exec(message)?.[1];
    return { status: 409, message: `This line is already ${status} — unmatch it before excluding it.` };
  }

  // restore_statement_line(): the line isn't excluded.
  if (/^statement line \d+ is not excluded$/.test(message)) {
    return { status: 409, message: "This line isn't excluded." };
  }

  // create_entry_from_line(): no category account supplied (bank.ts's own
  // zod schema already requires this; kept as defense-in-depth).
  if (/a category account is required/.test(message)) {
    return { status: 422, message: "Pick a category for this transaction." };
  }

  // create_entry_from_line(): the line has already moved past unmatched/
  // suggested (no "; unmatch it first" suffix — see the ordering note above
  // this function).
  if (/^statement line \d+ is already (\w+)$/.test(message)) {
    const status = /is already (\w+)$/.exec(message)?.[1];
    return {
      status: 409,
      message: `This line has already been ${status} — it can't be turned into a new entry now.`,
    };
  }

  // create_entry_from_line(): the chosen category IS the money account.
  if (/category account cannot be the money account itself/.test(message)) {
    return { status: 422, message: "Pick a different account than the money account itself for the category." };
  }

  // reconcile_statement(): already reconciled.
  if (/^statement \d+ is already reconciled$/.test(message)) {
    return { status: 409, message: "This statement has already been reconciled." };
  }

  // reconcile_statement(): missing endingBalance/throughDate (bank.ts's own
  // zod schema already requires both; kept as defense-in-depth).
  if (/endingBalance and throughDate are required to reconcile/.test(message)) {
    return { status: 422, message: "Enter the statement's ending balance and the date to reconcile through." };
  }

  // reconcile_statement(): unsettled lines remain.
  if (/still need a decision before this statement can be reconciled/.test(message)) {
    const count = /^(\d+) statement line\(s\)/.exec(message)?.[1];
    return {
      status: 409,
      message: count
        ? `${count} line${count === "1" ? "" : "s"} still need a decision before you can reconcile — confirm, match, create, or exclude them first.`
        : "Some lines still need a decision before you can reconcile — confirm, match, create, or exclude them first.",
    };
  }

  // reconcile_statement(): the balance gate itself — already good English
  // (this function's comment explicitly writes CLAUDE.md §9's own example
  // sentence), passed through unchanged.
  if (/^this doesn't add up — \$/.test(message)) {
    return { status: 409, message };
  }

  // undo_reconciliation(): nothing to undo.
  if (/^account \d+ has never been reconciled$/.test(message)) {
    return { status: 409, message: "This account has never been reconciled — there's nothing to undo." };
  }
  if (/^account \d+ is not currently reconciled$/.test(message)) {
    return { status: 409, message: "This account isn't currently reconciled — there's nothing to undo." };
  }

  // bank_statements_immutable_when_reconciled() trigger: defense-in-depth —
  // bank.ts never attempts to edit a reconciled statement's header fields.
  if (/^statement \d+ is reconciled and immutable; undo the reconciliation first$/.test(message)) {
    return {
      status: 409,
      message: "This statement has been reconciled and is locked — undo the reconciliation first if you need to change it.",
    };
  }

  // bank_statement_lines_guard() trigger's DELETE branch: unreachable through
  // any route here (nothing ever DELETEs a statement line), kept for
  // completeness per the migration's own "column-hardened / permanent
  // record" design.
  if (/statement lines are permanent; exclude line \d+ instead of deleting it/.test(message)) {
    return { status: 409, message: "Statement lines can't be deleted — exclude this line instead." };
  }

  // 0012_bank_rec_born_state.sql: born-state guards. Unreachable through any
  // route here (every INSERT this codebase writes already lands in the born
  // state), kept for completeness/defense-in-depth.
  if (/statements are created open; reconcile_statement\(\) finalizes them/.test(message)) {
    return { status: 422, message: "Something went wrong creating that statement. Nothing was saved." };
  }
  if (/statement lines are created unmatched; use the matching functions/.test(message)) {
    return { status: 422, message: "Something went wrong creating that statement line. Nothing was saved." };
  }

  return null;
}

// String surgery only — NUMERIC(19,4) can exceed Number's exact range, and
// money never goes through floats anywhere in this codebase (CLAUDE.md §2).
function formatAmount(raw: string): string {
  const m = /^-?(\d+)(?:\.(\d+))?$/.exec(raw.trim());
  if (!m) return raw;
  const whole = m[1].replace(/^0+(?=\d)/, "");
  const frac = ((m[2] ?? "") + "00").slice(0, 2);
  return `${whole}.${frac}`;
}
