# Invoices + locks (M6)

The money milestone: invoices as first-class documents with LINES, the
entry lock on `invoice_line_id`, bulk apply/detach on the M5 engine,
and the PlusROI paste block whose byte-format is cross-validated
against the legacy code of record.

Parity bar: `/srv/apps/work/docs/rebuild/01-current-system/features/invoices.md`
(B1–B76) with the rebuild deltas from 02-domain-model §3, judgment
calls #9/#22/#24, and 04-architecture (per-tenant config, mutation
feedback, money paths). Deviations: ADR #007.

## Behavior

### The invoice document (R1 = current shape, verbatim)

- `name` unique per tenant **case-insensitively** (`DUPLICATE_INVOICE_NAME`
  → `An invoice named "{name}" already exists.`); required, trimmed.
- `status ∈ open | sent | paid` — input trimmed + lowercased; anything
  else coerces to `open` (B3). **First-crossing stamps** (B9/B12):
  crossing into sent/paid stamps `sent_at`; into paid stamps `paid_at`;
  stamps are NEVER cleared or overwritten (paid→open→paid keeps the
  originals). Creating straight to `paid` stamps both.
- `invoice_date` defaults to the **last day of the previous month**
  (create form pre-fills it; the server also defaults a missing or
  malformed value). Calendar-validated — the legacy `2026-99-99`
  regex-passes-PG-rejects wart does not port. On UPDATE an empty or
  malformed value writes NULL (B66 parity, T-INV-012).
- `manual_total` (parseMoneyOrNull parity): optional 2dp override of
  the displayed Billout; blank clears back to the entry sum. It NEVER
  reaches the paste block (B51).
- `created_by` = the REAL human (`actor.real_user_id`), even in view-as.
- R1 deltas: `currency` (single-currency document, seeded from tenant
  settings `default_currency`), `from_party_id` (auto = the tenant
  party), `to_party_id` (optional counterparty).

### Lines + the lock (the rebuild's core delta)

- Applying an entry creates ONE `time`-kind `invoice_lines` row and
  stamps `time_entries.invoice_line_id` — **that FK is THE lock**
  (ADR #038 semantics carried; 02 §5 invariant 2).
- Each line **snapshots display fields as of attach** (judgment #9):
  `snapshot_client_name` / `snapshot_project_name` (from the SAME
  FK-display expressions /entries renders), `description`, `quantity`
  (= hours, 2dp half-up), `amount` (= the entry's billout stamp), plus
  `attached_at` / `attached_by` (the REAL user). `rate` stays NULL in
  R1 (migration parity — no read path uses it).
- Locked rows: single-row edit/delete/re-resolve refuse non-owners
  with 409 `ENTRY_LOCKED` (M5's guard); bulk recalculate skips locked
  rows for EVERYONE; bulk delete refuses an all-locked scope; bulk
  edit skips them for non-owners (all M5 rules, verified against M6
  locks by the lifecycle gate).

### Apply (owner-only; `/api/entries/bulk/apply-to-invoice`)

- Rides the M5 bulk engine: explicit ids OR all-matching with the SAME
  filter params the display used; `expected_count` REQUIRED — a stale
  count aborts with the exact `Safety stop:` message before any write.
- Eligibility is **THE ONE predicate** (`status_condition("pending")`)
  — display status and apply agree by construction (T-INV-010; the
  legacy operator-drift wart is dead).
- Already-attached rows are skipped and counted
  (`skipped_already_applied`); blocked rows are skipped and counted
  (`skipped_blocked`); nothing eligible → 409 `NOTHING_TO_APPLY`.
- A `paid` invoice takes no more entries (409 `INVOICE_PAID`; also
  excluded from the picker — B14). A foreign-currency billout stamp
  aborts the whole apply (409 `CURRENCY_MISMATCH`) — invoices are
  single-currency documents (02 §3).
- A PlusROI-lifecycle invoice (`pending_approval`/`ready`/`cancelled`/
  `delayed`) refuses outright (409 `PLUS_LIFECYCLE_INVOICE`) — those
  receivables carry no lines by design; see `plusroi-books.md` "The
  BW-endpoint guard" (ADR #024/#025).
- One transaction; verified counts; audit row with COMPLETE entry
  before-images + the created line ids.

### Detach (owner-only; `/api/entries/bulk/detach`)

- **Explicit selection only** (B62/T-INV-015): the endpoint refuses
  `all_matching` (422 `DETACH_EXPLICIT_ONLY`) and the bulk bar blocks
  it client-side with the legacy message.
- Clears `invoice_line_id` and DELETES the line; already-detached rows
  no-op and are counted (`skipped_unlocked`). With `invoice_id` given
  (the ?invoice= view) only that invoice's rows detach
  (`skipped_other_invoice` counts the rest).
- Set-based — >150 ids in one call succeeds (T-INV-022).
- Audit row carries entry AND line before-images.

### Undo policy for lock ops (ADR #007)

Apply/detach are NOT replayed by the generic `/api/entries/undo/…`
(they're excluded from `UNDOABLE_ACTIONS`; that machinery deliberately
never touches `invoice_line_id`). **The inverse operation is the
undo**: the apply toast offers "Undo (detach)" over the exact applied
ids; the detach toast offers "Undo (re-apply)" when the rows came off
one invoice. Each inverse is itself verified + audited.

### Delete (owner-only, GUARDED — rebuild delta)

`DELETE /api/invoices/{id}` refuses while lines exist (409
`INVOICE_HAS_LINES`) — the legacy delete detached everything silently
and destroyed the apply audit trail (B70/B71 wart). The flow is two
deliberate steps: detach (audited, counted) → delete (audited). The
UI disables Delete until the invoice is empty. Same PlusROI-lifecycle
refusal as Apply above (409 `PLUS_LIFECYCLE_INVOICE`) — this endpoint
must never delete a PlusROI receivable outright.

### Aggregates + views

- List (`GET /api/invoices`): newest-created first; per-invoice
  entries/hours/cost/billout/margin from ONE grouped SQL query over
  lines joined to **scope()-transformed** entries — a viewer with
  partial visibility sums only what they can see (T-INV-023); the
  unbounded client-side reduction (B17/B42) does not port.
- Margin math (B18): `effective billout = manual_total ?? Σ lines`;
  `margin = effective − cost`; pct to 1dp when effective > 0. Margin
  requires one currency on both sides — a mismatch raises the loud
  `MIXED_CURRENCY` error (R1 rule).
- Detail (`GET /api/invoices/{id}`): stats + per-(client × project)
  breakdown grouped by the LINE SNAPSHOTS (rename-immune), sorted
  client case-insensitive then project (B44); lines table (capped at
  500 rows inline with the true total); `?invoice=` deep link to
  /entries.
- Hours = Σ raw `duration_seconds`, rendered `H:MM:SS` (B42/B43).

### Paste block (`GET /api/invoices/{id}/paste-block`)

- Exact legacy byte-format (B50): 7 tab-separated columns —
  `{client} : {project}` (colon with spaces; `(no client)` /
  `(no project)` fallbacks), team label, amount (2dp, no symbol),
  invoice_date (NULL date → today's default, B49), empty column,
  operator label, category label; lines joined with `\n`; empty
  breakdown → empty string.
- The three business literals are **TENANT SETTINGS**
  (`tenants.settings.paste_block.{team_label,operator_label,category_label}`,
  04 "no hard-coded business literals"); the code defaults are the
  Bowden Works tenant's values (`Bowden Works Team` / `Bowden Works` /
  `Labour`) so R1 renders identically with no settings write.
- UI: server-rendered `<pre>` + "Copy to clipboard" using the
  pessimistic verified pattern — the button re-fetches the block,
  copies THAT text, and reports the verified line count; `✓ Copied`
  for 1.5s.

### Frontend

- BILLING → Invoices (`/invoices`): the mini P&L table (name, status
  pill with the B11 colors, counterparty, entries, hours, cost,
  billout with the ★ manual-override note + entry-sum tooltip, ±
  colored margin with pct, invoice date falling back to created date);
  owner-only inline create form (defaults: status open, date = last
  day of previous month, name placeholder `e.g. 2026-04 PlusROI`).
- `/invoices/{id}`: header + meta line, status-transition buttons
  (open→sent→paid, reopen), inline edit form, guarded delete,
  stats, breakdown, paste block, lines table, footer link
  `→ View the N attached entries` (`/entries?status=applied&invoice=`).
- /entries bulk bar (owners): **Apply to invoice** (picker of
  open/sent invoices newest first + create-new-inline; confirm quotes
  the exact count and the lock warning) and **Detach**
  (explicit-selection only; confirm + verified toast). The ?invoice=
  filter shows a clearable "Invoice: scoped ✕" chip and scopes detach
  to that invoice. Locked-row visuals carry from M5.

## Permissions

- **View** list/detail/paste block: any authenticated member; sums are
  scope()-limited (partial visibility = partial sums, exactly like the
  legacy RLS behavior).
- **Create / edit / delete / apply / detach:** `can_transfer` (owner;
  super-admin not in view-as) — enforced in the service with the
  legacy messages verbatim. There is no separate invoice capability
  (parity: invoices reuse the transfer permission).
- **Locked-entry writes:** `can_transfer` via M5's
  `edit_entry_capability(locked)`.

## Constraints / invariants

- `invoice_line_id IS NOT NULL` ⇔ locked; one invoice per entry
  (enforced by the skip-already-attached rule + the FK chain).
- Invoices are single-currency; every line inherits `invoice.currency`;
  apply rejects foreign-currency stamps.
- Money is Decimal end-to-end; amounts cross the API as strings.
- Snapshots are written ONCE at attach; entity renames never touch
  them (the stale-denormalized-names incident class is dead).
- No schema change was needed — `invoices` + `invoice_lines` shipped
  in M2 (alembic 0003) exactly as 02 §3 specifies.

## Gates (all in-tests, tests/invoices/)

- **Byte-diff gate** (`test_paste_block_gate.py`): for ALL 3 real
  migrated invoices, the Python paste block (migrated snapshots →
  SQL aggregation → tenant settings → formatter) is byte-identical to
  the legacy `/srv/apps/work/lib/invoices.ts`
  `getInvoiceProjectBreakdown` + `formatPasteBlock` run via the old
  app's tsx (`scripts/ts_paste_block.mjs`) over the same invoice's raw
  legacy rows. The 'Legacy transfers' oracle applies the judgment #22
  straggler rule (the Villa del Mar row) so membership matches the
  migration's deliberate delta.
- **Lifecycle gate** (`test_lifecycle_gate.py`): on the real-data
  scratch clone with REAL commits — create → apply 5 real pending
  entries → locked (non-owner edit 409s, recalculate skips, bulk
  delete refuses, status partition shifts by exactly 5) → paste block
  renders → detach → unlocked/pending again → delete → harness
  compare `doc_before == doc_after` (meta-only diffs).
- The m6 scratch DB (`with_m6_test`) clones the REAL legacy snapshot
  from the sidecar (COPY streams, read-only source) and re-runs the
  transforms — production-shaped data, zero risk to the real DB.

## Changelog

- **0.7.0 (2026-07-08, M6):** first version — invoices CRUD + lines +
  locks, bulk apply/detach on the M5 engine, paste block w/ tenant
  settings, /invoices UI, byte-diff + lifecycle gates. ADR #007.
- **0.7.1 (2026-07-13):** the PlusROI-lifecycle guard (ADR #024, first
  on `update_invoice` only) now also refuses `apply_entries` and
  `delete_invoice` on a `pending_approval`/`ready`/`cancelled`/`delayed`
  row (409 `PLUS_LIFECYCLE_INVOICE`) — ADR #025. `open`/`sent`/`paid`
  behavior is unchanged.
