# Invoices — current-system spec (parity bar for the rebuild)

> An invoice is a named, org-scoped bag of locked time entries plus scalar metadata. Rian creates one per billing cycle (e.g. "2026-04 PlusROI"), bulk-applies filtered entries to it from `/entries` (which locks them), reviews cost/billout/margin per (client × project) on the detail page, and copies a tab-separated paste block into the legacy PlusROI Google Sheet — the last remaining external-sheet dependency. Invoices replaced the old `/transfer` Toggl-CSV workflow as the canonical "this entry is billed" signal (ADR #038; rian, 2026-05-25: "the effect will be the same as 'transfer' where it locks the entries. In fact, it can replace transfer altogether"). There is no line-items model, no PDF, no numbering, no currency other than USD — amounts are computed live from attached `time_entries` rows with one optional scalar override.

## User workflows

1. **Month-end billing (the core loop):** create invoice named `YYYY-MM PlusROI` → go to `/entries`, filter (typically previous month + status=pending) → select rows (or "select all matching") → "Apply to invoice" → open `/invoices/[id]` → verify Entries/Hours/Cost/Billout/Margin and the per-(client × project) table → "Copy to clipboard" on the paste block → paste into the PlusROI Google Sheet starting at its Description column → later flip status open → sent → paid.
2. **Correction:** a mis-applied entry must be detached (owner-only) on `/entries` before it can be edited or applied to a different invoice — apply silently skips already-attached rows, so moving an entry = detach first, then re-apply.
3. **Margin review:** the `/invoices` list is a mini P&L — per-invoice cost vs billout vs margin ($ and %), with a "manual override" marker when the printed total was negotiated away from the entry sum.
4. **Undo everything:** deleting an invoice detaches and unlocks every attached entry (used when a billing run was wrong).

## Complete behavior

### Creation

- B1. Only users passing `canTransfer(eu)` can create invoices; failure redirects with `error=Only an owner can create invoices.`
- B2. `name` is required (trimmed); empty → `error=Invoice name is required.` Names are unique per org **case-insensitively** (unique index `invoices_org_name_lower_uq` on `(org_id, lower(name))`); PG error code `23505` is mapped to `An invoice named "{name}" already exists.`
- B3. `status` input is trimmed + lowercased; only `open | sent | paid` accepted; anything else (including absent) coerces to `open`. Form default is `open`.
- B4. `invoice_date` defaults to **the last day of the previous month** via `defaultInvoiceDate(today = new Date())` → `new Date(y, m, 0)` formatted `YYYY-MM-DD` (server-local timezone). Rationale: "create the April 30 invoice anytime in May without rewriting the date." The create form pre-fills this value; the server also falls back to it when the posted value is empty or fails the regex `/^\d{4}-\d{2}-\d{2}$/` (`parseDateOrNull`). Note the regex does no calendar validation — `2026-99-99` passes JS and is rejected by Postgres `date`.
- B5. `operator_id` / `client_id` are optional uuid scope chips (FKs to `operators`/`clients`); empty string → NULL. They are labels only — nothing filters or validates attached entries against them. The client dropdown is NOT filtered by the chosen operator (clients' `operator_id` is fetched but unused).
- B6. `manual_total_usd`: optional; `parseMoneyOrNull` trims, `''`/non-finite → NULL, else rounds to 2dp via `Math.round(n*100)/100`. Column is `numeric(10,2)` (narrower than the `12,2` used elsewhere).
- B7. `notes`: optional trimmed text, empty → NULL.
- B8. `created_by` is set to `eu?.real_user_id ?? null` — always the REAL human, even in view-as mode.
- B9. **Status back-dating on create:** creating directly with status `sent` or `paid` stamps `sent_at = now`; `paid` also stamps `paid_at = now`.
- B10. On success: `revalidatePath('/invoices')` then redirect to `/invoices/{new id}`. Other insert errors surface raw `error.message` in the `?error=` param.

### Status semantics

- B11. `status ∈ {open, sent, paid}` (text column with CHECK constraint, not a PG enum). Badges: Open = green `#0a7d3f`, Sent = blue `#0369a1`, Paid = gray `#6b7280` (white text, pill).
- B12. **First-crossing timestamp stamping on update:** `sent_at = now` iff new status ∈ {sent, paid} AND existing `sent_at IS NULL`; `paid_at = now` iff new status = paid AND existing `paid_at IS NULL`. Toggling back to open/sent NEVER clears or overwrites the timestamps ("auditing hates that").
- B13. No DB constraint ties `status` to the timestamps — `status='paid'` with NULL `paid_at` is representable (the migration-created "Legacy transfers" invoice is exactly that).
- B14. `paid` invoices are excluded from the `/entries` "Apply to invoice" picker (query: `.in('status', ['open','sent'])`, newest-created first). To apply onto a paid invoice, edit its status back first.

### List view (`/invoices`, `app/(app)/invoices/page.tsx`)

- B15. Any org member can view (RLS `is_org_member(org_id)` on all four `invoices` policies). Only `canTransfer` users see "+ New invoice" (link `/invoices?new=1`, `scroll={false}`) and the inline create form (`showNew = sp.new === '1' && userCanTransfer`). The nav link is `ownerOnly: true` (`components/nav.tsx`), so non-owners only reach it by URL.
- B16. Invoices are listed newest-`created_at` first (`listInvoices` in `lib/invoices.ts`, which joins `operator:operators(name)` and `client:clients(name)` for the scope columns).
- B17. Per-invoice aggregates come from ONE PostgREST query over ALL invoiced entries in the org: `time_entries.select('invoice_id, billout_amount_usd, billout_cost_usd').eq('org_id', …).not('invoice_id','is',null)`, reduced client-side into `Map<invoice_id, {count, billout, cost}>`. Unbounded — a known scaling wart.
- B18. Row math: `effectiveBillout = inv.manual_total_usd ?? summedBillout`; `margin = effectiveBillout − summedCost`; `marginPct = effectiveBillout > 0 ? margin/effectiveBillout*100 : null` (shown to 1 decimal).
- B19. Columns: Name (link to detail) · Status badge · Operator name (`—` if null) · Client name (`—`) · Entries (count) · Cost (`fmtUsd`, e.g. `$1,593.89`) · Billout (`fmtUsd(effectiveBillout)`) · Margin (green `#0a7d3f` with `+` prefix when ≥ 0, red `#b91c1c` when negative; pct in small muted line under) · Invoice date.
- B20. Billout cell shows a small muted "manual override" note (with tooltip `Sum of entries: {fmtUsd(summedBillout)}`) when `manual_total_usd != null && summedBillout !== manual_total_usd`.
- B21. Invoice date column renders `inv.invoice_date ?? inv.created_at.slice(0,10)` (fallback for pre-column legacy rows).
- B22. Empty state: "No invoices yet." plus a "Create the first one" link for owners. Flash messages arrive via `?error=` / `?info=` query params (all invoice actions communicate by redirect, no toasts).

### Applying entries (on `/entries` — `bulkApplyToInvoice` in `app/(app)/entries/actions.ts`)

- B23. Owner gate: `canTransfer(eu)` else `error=Only an organization owner can apply entries to invoices.`
- B24. Requires `invoice_id` form field (from the bulk-bar `<select name="invoice_id">`); empty → `Pick an invoice first.` The invoice is validated to belong to the app org via an explicit `invoices` lookup (guards against a stale form posting another org's uuid past RLS) → `Invoice not found.`
- B25. Update payload stamped onto each row: `invoice_id`, `invoice_applied_at = now (ISO)`, `invoice_applied_by = eu?.real_user_id ?? null`.
- B26. Two selection modes: explicit ids (repeated `id` fields; empty → `No entries selected.`) or `all_matching=1`, which re-derives the row set server-side from the page's filter fields via `readBulkFilter` + `applyBulkFilter` (defaults `date: 'all'`, `status: 'pending'` — ADR #034: the mutation filter must match what the user sees).
- B27. Both modes hard-filter `.is('invoice_id', null)` — **rows already attached to ANY invoice are silently skipped**. One invoice per entry; enforced by this filter, not by schema.
- B28. Both modes require these columns non-NULL inline on the UPDATE: `team_member_id, client, project, description, end_at, source_user_email`. **`operator` is NOT in this inline guard** — see Edge cases (the canonical eligibility list `ELIGIBILITY_COLUMNS` = `team_member_id, operator, client, project, description, end_at, source_user_email` includes it and drives the pending/blocked status filters). Toggl-only `converted_user`/`converted_duration_seconds` are deliberately NOT required for invoices.
- B29. Ineligible/already-attached rows are silently skipped, never errored; the actual applied count comes from `.select('id')` on the UPDATE.
- B30. Success: revalidates `/entries`, `/invoices`, `/invoices/{id}`, `/`; redirects to `/invoices/{invoiceId}?info=Applied+{count}+entries.`
- B31. Explicit-id mode is NOT chunked here either (single `.in('id', ids)`) — same URL-length risk class as detach (see Edge cases), though the apply UI posts what fits in one form.
- B32. The bulk bar's confirm dialog reads the selected option's label: `Apply {N} entries to "{invoiceLabel}"? They'll be locked — only an owner can detach them.` No invoice picked → `alert('Pick an invoice from the dropdown first.')`.
- B33. When no open/sent invoices exist, the bar shows "No open invoices." with a link to `/invoices?new=1`.

### Lock semantics

- B34. `time_entries.invoice_id IS NOT NULL` ⇒ the entry is **locked**. `canEditEntry(eu, entry)` in `lib/permissions.ts`: `if (entry.invoice_id || entry.transferred_at) return canTransfer(eu)` — only owners/super-admins (not in view-as) can edit, delete, re-resolve, or detach a locked row. The `transferred_at` branch is kept only as a belt-and-braces legacy lock (pre-invoice Toggl era).
- B35. Server actions on `/entries` enforce the lock with explicit messages: edit → `This entry is attached to an invoice and locked. Only an owner can edit it.`; re-resolve → `…Only an owner can re-resolve it.`; delete → `…Only an owner can delete it.`
- B36. `/entries` status filter semantics: `applied` (alias `transferred`) = `invoice_id IS NOT NULL OR transferred_at IS NOT NULL`; `pending` = both NULL AND all `ELIGIBILITY_COLUMNS` non-NULL; `blocked` = both NULL AND at least one eligibility column NULL. `?invoice=<uuid>` additionally scopes to `invoice_id = <uuid>`.
- B37. Entry rows show a lock icon with title `Applied to invoice: {invoice_name}` (joined `invoice:invoices(name)`); legacy transferred-but-not-backfilled rows show `Transferred (legacy — pre-invoice)`; pending rows `Pending — ready to apply to an invoice`.

### Detail view (`/invoices/[id]`, `app/(app)/invoices/[id]/page.tsx`)

- B38. Any org member can view; missing/foreign id renders an in-page "Invoice not found" + back link (HTTP 200, not 404).
- B39. Header: "← All invoices" back link; name + status badge; meta line `Invoice date **{invoice_date}** · Created {created_at[:10]} · Sent {sent_at[:10]} · Paid {paid_at[:10]}` (each segment only when non-null).
- B40. Owner-only header buttons: **Edit** (`?edit=1`; `editing = sp.edit === '1' && userCanTransfer`) and **Delete** (`DeleteForm` posting the id under field name `id`, `window.confirm` text: `Delete invoice "{name}"? Attached entries will be detached and unlocked. This can't be undone.`).
- B41. Notes render as a gray callout below the header when set and not editing.
- B42. Totals (`getInvoiceTotals`, `lib/invoices.ts`) fetch `duration_seconds, billout_cost_usd, billout_amount_usd` for `org_id + invoice_id` and reduce **client-side in Node**: `row_count` = rows.length; `source_seconds` = Σ `duration_seconds ?? 0` (RAW source seconds — not the Toggl-converted duration); `cost_usd` = Σ `Number(billout_cost_usd ?? 0)`; `billout_amount_usd` = Σ `Number(billout_amount_usd ?? 0)`. RLS applies, so a viewer with partial visibility sees partial sums.
- B43. Stats panel: Entries (`toLocaleString()`) · Hours (`fmtHoursMinutesSeconds(source_seconds)` → `H:MM:SS`, hours unpadded and can exceed 24) · Cost (`fmtUsd`) · Billout (green; when `manual_total_usd != null` the label gets a `★` and tooltip `Manual total override. Sum of entries: {fmtUsd(sum)}`) · Margin (`+`-prefixed/green or red, with `({pct}%)` to 1 decimal). Same `effectiveBillout` math as B18.
- B44. Per (client × project) breakdown (`getInvoiceProjectBreakdown`): fetch `client, project, duration_seconds, billout_cost_usd, billout_amount_usd` for the invoice; group client-side by key `` `${client ?? ''}::${project ?? ''}` `` (the denormalized TEXT columns on `time_entries`, not FKs); sum same fields as B42; sort client case-insensitive `localeCompare`, then project `localeCompare`.
- B45. Breakdown table columns: Client · Project · Entries · Hours (`fmtHoursMinutesSeconds`) · Cost · Billout · Margin (± colored, pct beneath). Row margin is computed from the row's own billout − cost (manual override does NOT distribute to rows). NULL client/project render as muted `—`.
- B46. Breakdown empty state: `No entries attached yet. Go to /entries, filter the rows you want, and use the bulk "Apply to invoice" action.`
- B47. Footer link: `→ View the {row_count} attached entries on /entries` → `/entries?status=applied&invoice={id}`.
- B48. Totals and breakdown load in parallel (`Promise.all`); errors from the helpers are swallowed to empty arrays (`data ?? []`).

### Paste block (`formatPasteBlock` in `lib/invoices.ts` + `PasteBlock` client component)

- B49. Panel title: "Paste block for the PlusROI sheet". Server pre-renders the text: `formatPasteBlock(breakdown, invoice.invoice_date ?? defaultInvoiceDate())` — a NULL-dated invoice uses today's last-day-of-previous-month default as column 4.
- B50. One line per (client × project) breakdown row, columns joined with `\t` (7 columns = 6 tabs), lines joined with `\n`, empty string when no rows. Exact layout (per user direction 2026-05-25):
  1. `{client} : {project}` — colon **with spaces** (matches the Toggl export project format, ADR #029); NULL fallbacks are the literals `(no client)` / `(no project)`.
  2. `Bowden Works Team` — fixed literal (the vendor string in the destination sheet). Rian explicitly corrected an earlier draft that used "Bowden Works" here.
  3. `{billout_amount_usd.toFixed(2)}` — `XX.XX`, **no currency symbol**, no thousands separators.
  4. `{invoice_date}` — `YYYY-MM-DD`.
  5. *(empty)* — intentional; the destination column is blank.
  6. `Bowden Works` — second fixed literal (the operator).
  7. `Labour` — fixed category literal.
- B51. Amounts are the per-row **entry sums** — `manual_total_usd` does NOT flow into the paste block (it only overrides headline Billout/Margin). Nor do project-level billout adjustments (display-only per ADR #040).
- B52. It is a **paste block, not a file download** — rian: "all I need is a tab separated block of text that I can copy and paste into the final sheet." Destination is a Google Sheet paste "starting at the Description column."
- B53. `PasteBlock` (`'use client'`, props `{ text: string }`) renders a `<pre>` (`whiteSpace: 'pre'`, horizontal scroll, `#f5f5f5` background) plus a "Copy to clipboard" button using `navigator.clipboard.writeText(text)`; success shows `✓ Copied` for 1500 ms; clipboard failure is silently swallowed (fallback: triple-click the `<pre>` + Ctrl+C).
- B54. Empty text → `Apply some entries to this invoice first — the paste block needs at least one (client × project) row to render.`
- B55. Instruction line above the block: "One line per (client × project). Paste into the PlusROI sheet starting at the Description column."
- B56. ⚠ Comment drift in `paste-block.tsx`: its doc-comment example shows column 2 as `Bowden Works` — the implementation emits `Bowden Works Team`. Code is authoritative.
- B57. `formatPasteBlock` is covered by unit tests INV-P-001…INV-P-007; `defaultInvoiceDate` by INV-D-001…INV-D-004 (`tests/invoices.test.ts`, passing).

### Un-applying / detaching (`bulkRemoveFromInvoice` in `app/(app)/invoices/actions.ts`, triggered from `/entries` bulk bar)

- B58. Owner gate: `error=Only an owner can detach entries.`
- B59. Inputs: repeated `id` fields (empty → `No entries selected.`) plus optional `invoice_id` used ONLY to choose the return path (`/invoices/{invoice_id}` else `/entries`).
- B60. Update: sets `invoice_id = null, invoice_applied_at = null, invoice_applied_by = null` scoped by `org_id`, `.in('id', ids)`, and `.not('invoice_id','is',null)` (no-op on already-detached rows); `.select('id')` yields the real detached count.
- B61. Success flash: `Detached {count} entries.` Revalidates `/invoices`, `/entries`, and `/invoices/{invoice_id}` when provided.
- B62. **Explicit selection only** — the client blocks "select all matching" mode with `alert('Detach only works on an explicit selection. Uncheck "select all matching" and pick rows.')`; the server action has no `all_matching` path. Deliberate: keeps destructive lock-removal scoped to checked rows. Confirm dialog: `Detach {N} entries from their invoice? They'll go back to unlocked / Pending.`
- B63. ⚠ NOT chunked: one `.in()` over a user-controlled id list, unlike the transfer flow's `chunkIds` (150 ids/chunk; `lib/db-chunks.ts` says any user-controlled `.in()` MUST chunk — very large detaches can fail with `TypeError: fetch failed` behind Cloudflare).

### Editing (`updateInvoice`)

- B64. Owner gate: `Only an owner can edit invoices.` (redirects back to `/invoices/{id}`). Requires hidden `id` (`Missing invoice id.`); row must exist in org (`Invoice not found.`).
- B65. Editable fields: `name` (required, same 23505 duplicate mapping), `status`, `operator_id`, `client_id`, `manual_total_usd`, `notes`, `invoice_date`. Timestamps per B12.
- B66. ⚠ Unlike create, **`invoice_date` has NO server-side default fallback on update** — an empty or malformed posted value writes NULL. The edit form pre-fills `invoice.invoice_date ?? defaultInvoiceDate()`, so a normal browser always sends a value, and saving a legacy NULL-date invoice silently backfills the default.
- B67. `manual_total_usd` edit field pre-fills `toFixed(2)` or `''`; blank clears the override (back to entry-sum billout).
- B68. Success: revalidates `/invoices`, `/invoices/{id}`, `/entries`; redirect `/invoices/{id}?info=Saved.`

### Deletion (`deleteInvoice`)

- B69. Owner gate: `Only an owner can delete invoices.` Confirm text per B40.
- B70. **Detach-first:** explicitly updates all attached `time_entries` to `invoice_id = null, invoice_applied_at = null, invoice_applied_by = null` (scoped `org_id` + `invoice_id`), THEN deletes the invoice row. The FK `time_entries.invoice_id → invoices(id)` is `ON DELETE SET NULL` anyway, but the explicit pass also clears the audit columns.
- B71. ⚠ Consequence: deleting an invoice **destroys the application audit trail** — nothing archives which entries were on it or when they were applied. Accepted wart in the current tool; not acceptable for real bookkeeping.
- B72. Success: revalidates `/invoices`, `/entries`; redirect `/invoices?info=Invoice+deleted.` All entries become unlocked/Pending again.

### Legacy backfill (migration `20260525000010_invoices.sql`)

- B73. The migration created ONE `'Legacy transfers'` invoice per org (`status='paid'`, explanatory `notes`, no `sent_at`/`paid_at`, no `invoice_date`) and attached every entry with `transferred_at IS NOT NULL`, copying `invoice_applied_at = transferred_at` and `invoice_applied_by = transferred_by` verbatim.
- B74. `transferred_at / transferred_by / transfer_batch_id` remain on `time_entries` as historical audit only; new writes never touch them. Partial index `time_entries_invoice_id_idx` on `(invoice_id) WHERE invoice_id IS NOT NULL`.
- B75. Related migrations: `20260525000011_invoice_status_filter.sql` (rewrites the SQL pending/blocked predicates in `entries_filter_totals` / `entries_filter_options` / `project_summary` to the invoice-era eligibility set) and `20260525000020_invoice_date.sql` (adds the `invoice_date date NULL` column; the last-day-of-previous-month default is app-side, not a DB default).
- B76. `invoices` has no `updated_at` column/trigger (unlike entity tables).

## Data model

### `invoices` (created `20260525000010`, `invoice_date` added `20260525000020`)

| Column | Type | Semantics |
|---|---|---|
| `id` | uuid PK | `gen_random_uuid()` |
| `org_id` | uuid NOT NULL | FK → `organizations` ON DELETE CASCADE; every query filters on it (single-org app via `getAppOrg`) |
| `name` | text NOT NULL | Unique per org case-insensitively (`invoices_org_name_lower_uq` on `(org_id, lower(name))`) |
| `status` | text NOT NULL default `'open'` | CHECK `in ('open','sent','paid')` — text, not enum |
| `operator_id` | uuid NULL | FK → `operators` ON DELETE SET NULL; display-only scope chip |
| `client_id` | uuid NULL | FK → `clients` ON DELETE SET NULL; display-only scope chip |
| `manual_total_usd` | numeric(10,2) NULL | Billout override for display totals; NULL = use Σ entries. Never reaches the paste block. (10,2 vs 12,2 elsewhere — inconsistency) |
| `notes` | text NULL | |
| `created_by` | uuid NULL | FK → `auth.users` ON DELETE SET NULL; always the REAL user (`real_user_id`), even in view-as |
| `created_at` | timestamptz NOT NULL default now() | List sort key (desc) |
| `invoice_date` | date NULL | User-facing accounting date; app default = last day of previous month; NULL on pre-column rows |
| `sent_at` / `paid_at` | timestamptz NULL | First-crossing stamps; never cleared; no constraint tying them to `status` |

Indexes: `invoices_org_status_idx (org_id, status)`, `invoices_operator_idx (operator_id)`, `invoices_client_idx (client_id)`, plus the unique name index.
RLS: all four policies (`invoices_org_select/insert/update/delete`) are `is_org_member(org_id)` — write restriction to owners is **app-layer only** (`canTransfer` in server actions), not enforced by RLS.

### `time_entries` columns this feature reads/writes

- `invoice_id` uuid NULL — FK → `invoices(id)` **ON DELETE SET NULL**. **NOT NULL = locked.** The canonical lock. Partial index where NOT NULL.
- `invoice_applied_at` timestamptz NULL, `invoice_applied_by` uuid NULL (FK → `auth.users` ON DELETE SET NULL) — apply audit; backfilled from `transferred_at/by`; cleared on detach/delete.
- `transferred_at` / `transferred_by` / `transfer_batch_id` — legacy Toggl-era lock, audit-only since the invoice migration; still honored by `canEditEntry` and the applied/pending filters.
- Read for math/display: `duration_seconds` (raw source seconds → "Hours"), `billout_cost_usd` (→ Cost), `billout_amount_usd` (→ Billout), `client`, `project` (denormalized TEXT — grouping keys for breakdown + paste block), plus eligibility columns `team_member_id, operator, description, end_at, source_user_email`.

### `operators` / `clients` (read-only here)

`operators(id, name)` and `clients(id, name, operator_id)` per org, ordered by name — populate the scope dropdowns on create/edit forms. `clients.operator_id` is fetched but never used to cascade the dropdown.

## Permissions

- **View** (list + detail + paste block): any authenticated org member (RLS `is_org_member`). Nav link hidden for non-owners (`ownerOnly: true`); direct URL renders read-only (no Edit/Delete/create form).
- **Create / edit / delete invoices, apply entries, detach entries:** `canTransfer(eu)` (`lib/permissions.ts`) — true when `eu.is_super_admin && !eu.is_viewing_as`, else `eu.org_role === 'owner'`; false for null. There is no separate `canInvoice`; invoices reuse the transfer permission. In practice: rian only; Adi/managers see but cannot touch.
- **Edit/delete/re-resolve a locked entry:** `canEditEntry(eu, entry)` → requires `canTransfer` when `invoice_id` or `transferred_at` is set.
- Audit columns (`created_by`, `invoice_applied_by`) always record `eu.real_user_id` — the actual human, never the view-as target.
- RLS does NOT restrict invoice writes to owners (app-layer gate only) — a rebuild should enforce role in policy too.

## Edge cases & error behavior

- All server actions use `fail(msg, retPath)` → `redirect('{retPath}?error={msg}')`; nothing throws to the UI. Exact messages: `Organization not found.` · `Only an owner can create invoices.` / `…edit invoices.` / `…delete invoices.` / `…detach entries.` · `Only an organization owner can apply entries to invoices.` · `Invoice name is required.` · `An invoice named "{name}" already exists.` · `Could not create invoice.` · `Missing invoice id.` · `Invoice not found.` · `No entries selected.` · `Pick an invoice first.`
- **Eligibility drift (wart):** `ELIGIBILITY_COLUMNS` (drives pending/blocked filters, includes `operator`) ≠ `bulkApplyToInvoice`'s inline guard (omits `operator`). An entry missing only `operator` shows as **blocked** in the filter yet CAN be applied via explicit-id selection (in all-matching mode the default `status=pending` filter re-adds the operator requirement). The old feature doc claims operator is checked on apply — code says otherwise. The eligibility predicate exists in 3+ places (JS `ELIGIBILITY_COLUMNS`, JS `blockerReason` in `lib/toggl.ts`, SQL in `entries_filter_totals`/`entries_filter_options`/`project_summary`) with a "update all together" comment — centralize in the rebuild.
- Apply/detach silently skip ineligible rows — counts in the flash message are the source of truth (rian deeply cares that confirmation counts match reality — the 343-vs-415 incident).
- `bulkRemoveFromInvoice` (and explicit-id apply) are NOT chunked; >~150 ids in one `.in()` risks `TypeError: fetch failed` (URL length). Transfer's equivalent used `chunkIds` (150/chunk).
- `updateInvoice` can write `invoice_date = NULL` (no fallback, B66); editing a legacy NULL-date invoice silently backfills the default via the form.
- `parseDateOrNull` regex accepts impossible dates (`2026-99-99`); Postgres rejects at write → raw PG error in `?error=`.
- Deleting an invoice wipes apply-audit on entries; nothing archives invoice membership (B71).
- Creating two invoices with names differing only by case fails with the duplicate message (case-insensitive unique index).
- `manual_total_usd` vs paste block mismatch: an overridden invoice pastes per-row entry sums that don't add up to the override — known and accepted (documented in the old feature doc §1.5).
- Detail helpers swallow query errors (`data ?? []`) — a failed fetch renders zeros, not an error page.
- Timezone naivety: `defaultInvoiceDate` uses server-local time; near-midnight-UTC edge cases shift the default by a day.
- View-as: a super-admin viewing-as a non-owner loses `canTransfer` (buttons vanish, actions fail); sums respect the impersonated user's RLS visibility (partial sums possible).

## UI notes

- Two-page UI: `/invoices` (list + inline `?new=1` create form) and `/invoices/[id]` (stats + breakdown + paste block + inline `?edit=1` form). Inline forms toggled by query param — no modals. `scroll={false}` on the toggle links to avoid the page-jump behavior rian hates.
- Feedback exclusively via `?error=` / `?info=` flash paragraphs after redirects. Counts in messages (`Applied N entries.`, `Detached N entries.`) are real post-update counts.
- The list page subtitle is the canonical lock statement: "Each invoice groups time entries you've billed for. Applying entries to an invoice locks them — only an owner can detach them again."
- Rian's taste (transcript §17), applied here and to keep in the rebuild: **paste-block outputs over file downloads** (requested twice — invoices and CC expenses); sensible defaults everywhere (invoice date = last day of previous month; entries bulk filter defaults all-dates+pending; name placeholder `e.g. 2026-04 PlusROI` mirrors his real naming); confirmation counts must match reality; compact tables, color-as-state (green/red margins, status pill colors), important numbers big — not "small grey font"; progressive disclosure (create/edit tucked behind `?new=1`/`?edit=1`, destructive Delete de-emphasized as a red link with confirm).
- Dislikes to avoid repeating: no progress indicator on slow actions; the stale-denormalized-names incident (invoice showed old client names after a bulk rename, 05-25 — never fully fixed, drives judgment call #9); redundant fields and messy accordions.
- Status badge colors duplicated verbatim in both pages (`STATUS_BADGE` map) — trivial dedupe candidate.

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md`, `02-domain-model.md`, `04-architecture.md`, `05-migration-plan.md`, `07-judgment-calls.md`:

- **R1 keeps today's invoice shape** (02 §3 invoices + invoice_lines; judgment call #24): `name` (unique per tenant, case-insensitive), `status` open/sent/paid with independent `sent_at`/`paid_at` stamps, `invoice_date`, `created_by`, `notes`, `manual_total_usd` all carry verbatim. R1 *adds* only `from_party_id`/`to_party_id` (seeded from the operator/client scope chips) and the `invoice_lines` table: `kind (time|expense|adjustment|manual), description, quantity, rate, amount`, snapshot display fields, `attached_at`/`attached_by` (carrying `invoice_applied_at`/`invoice_applied_by`). The entry lock moves from `time_entries.invoice_id` to `invoice_line_id`; CC expense lines get the same lock. "Applied to invoice" replaces "transferred" everywhere; `transferred_*`, `converted_user`, `converted_duration_seconds` are dropped. The richer vocabulary (`number`, draft/issued/void, `issued_on`/`due_on`, engagement-driven generation) is R4-only.
- **Snapshot-at-attach** (judgment call #9): lines copy display fields (client, project, description, amounts) at attach time — fixes the stale-name inconsistency; live entities stay FK-linked for reporting.
- **Legacy transferred rows** (05 §step 2; judgment call #22): expected count of `transferred_at IS NOT NULL AND invoice_id IS NULL` rows is **zero** — the B73 backfill already attached them all; the migration asserts and reports the count. Any stragglers attach to the *existing* per-ORG **`'Legacy transfers'`** invoice (the same one migration `20260525000010` created — never per-operator, no new sentinel invoices) with a WARNING in the run report for owner review; never abort. One lock concept, no standalone flag. Existing invoices' entries become `time`-kind lines snapshotting names as of migration.
- **Service layer + SQL aggregates** (04): all invoice math in `services/money/invoices.py`; create+lock is transactional; attach-lock enforced by DB constraint + service check; money is `Numeric(12,2)`, never float. Replaces the client-side Node reductions (B17, B42, B44).
- **Phasing** (00): R1 = manual-grouping parity with this spec (diff-harness must reproduce invoice totals); R4 = engagement-driven invoice generation, revenue_events ledger, commissions/profit-share. The PlusROI paste block survives R1 (last-sheet dependency; long-term goal is producing PlusROI's books natively, retiring it).
- Fix deliberately in rebuild: owner-role enforcement in RLS/policy, one centralized eligibility predicate, chunked (or set-based server-side) bulk mutations, archive-on-delete (void instead of hard delete once invoices are real documents), org-level timezone for date defaults.

## Test specs

Existing automated (passing, `tests/invoices.test.ts`):
- T-INV-D-001: given 2026-05-15 / when `defaultInvoiceDate` / then `2026-04-30`.
- T-INV-D-002: given the 1st of a month / then last day of preceding month.
- T-INV-D-003: given January / then previous December 31.
- T-INV-D-004: given March in leap and non-leap years / then Feb 29 vs Feb 28.
- T-INV-P-001: given empty breakdown / then `''`.
- T-INV-P-002: given one row / then one line, 7 tab-separated columns in exact order (col2 `Bowden Works Team`, col6 `Bowden Works`, col7 `Labour`).
- T-INV-P-003: amount rendered `toFixed(2)`, no `$`.
- T-INV-P-004: multiple rows join with `\n`, caller's order preserved.
- T-INV-P-005: NULL client/project → `(no client) : (no project)`.
- T-INV-P-006: colon-with-spaces separator.
- T-INV-P-007: 5th column empty.

Carried from the old feature doc (specified, not yet automated):
- T-INV-001: given an existing invoice "ABC" / when creating "abc" / then duplicate-name error (case-insensitive).
- T-INV-002: applying entries stamps `invoice_id` + `invoice_applied_at` + `invoice_applied_by` (= real user).
- T-INV-003: detaching clears all three.
- T-INV-004: open→sent stamps `sent_at`; →paid stamps `paid_at`; paid→open→paid round-trip preserves the original timestamps.
- T-INV-005: deleting an invoice detaches attached entries and they become editable by non-owners again.
- T-INV-006: manual total override changes displayed Billout + Margin on list and detail.
- T-INV-007: breakdown sums equal Σ per-entry `billout_amount_usd` per (client, project).

New (uncovered behaviors):
- T-INV-008: given entry already on invoice A / when bulk-applying to invoice B / then silently skipped and flash count excludes it (B27).
- T-INV-009: given entry with NULL `client` / when applied by explicit id / then skipped (inline guard, B28).
- T-INV-010: given entry with NULL `operator` only / then status filter shows "blocked" BUT explicit-id apply attaches it (wart; rebuild should make these agree) (B28, Edge cases).
- T-INV-011: given create with status `paid` / then `sent_at` AND `paid_at` both stamped at create (B9).
- T-INV-012: given create form posts no/malformed `invoice_date` / then row gets `defaultInvoiceDate()` (B4); given update posts empty date / then `invoice_date` becomes NULL (B66).
- T-INV-013: given non-owner (or super-admin in view-as) / when POSTing createInvoice/updateInvoice/deleteInvoice/bulkApplyToInvoice/bulkRemoveFromInvoice / then redirected with the owner-only error and no write (B1, B23, B58, B64, B69).
- T-INV-014: given locked entry / when non-owner edits/deletes/re-resolves / then blocked with the lock message (B34–B35).
- T-INV-015: given detach posted with `all_matching` UI state / then client blocks it; server action only accepts explicit ids (B62).
- T-INV-016: given detach ids including already-detached rows / then no-op on those and flash count = actually-detached (B60–B61).
- T-INV-017: given invoice with `manual_total_usd` set / then paste block amounts still equal per-row entry sums (B51).
- T-INV-018: given invoice with NULL `invoice_date` / then paste block column 4 = `defaultInvoiceDate()` and list date column falls back to `created_at[:10]` (B21, B49).
- T-INV-019: given `?invoice=<id>&status=applied` on /entries / then rows scoped to that invoice; footer link count matches detail `row_count` (B36, B47).
- T-INV-020: given `paid` invoice / then absent from the /entries apply picker; flipping to `sent` restores it (B14).
- T-INV-021: given list page / then margin green with `+` when ≥ 0, red when negative; "manual override" note only when override ≠ entry sum (B19–B20).
- T-INV-022: given >150 ids to detach / then (current) risk of request failure — rebuild: must succeed via chunk/set-based mutation (B63).
- T-INV-023: given viewer with partial RLS visibility / then detail totals/breakdown sum only visible entries (B42).
- T-INV-024: given `all_matching=1` apply with a filter / then only rows matching BOTH the filter and eligibility get attached, and the flash count equals rows actually stamped (B26–B29; regression family of the 343-vs-415 incident).
