# Tools → Bill-Through CC Expenses — current-system spec (parity bar for the rebuild)

> Pass-through billing helper for the legacy PlusROI Google Sheet. Rian pastes raw credit-card
> statement lines into a named batch, assigns each line to a `(client × project)` plus a
> free-text description and category (manually, or via keyword auto-assign rules), and copies a
> tab-separated paste block into the PlusROI sheet. Highlight keywords flag probably-assignable
> lines yellow; a 3-color row scheme (green saved > orange auto-matched > yellow keyword) tracks
> review state. Shipped in two phases on 2026-05-25. All owner-only. Code:
> `app/(app)/tools/cc-expenses/` (+ `app/(app)/tools/page.tsx` landing), pure helpers in
> `lib/cc-expenses.ts`, tables in migrations `20260525000040_cc_expenses.sql`,
> `20260525000050_cc_rules.sql`, `20260525000060_cc_auto_assigned.sql`.

## User workflows

1. **Monthly PlusROI bill-through** (the reason it exists — rian, 2026-05-25: "I need to paste
   in items from my credit card statement... ability to assign some of those to a project...
   generate a tab-separated paste block I can copy into another sheet"):
   create batch "April 2026 PlusROI" → paste the month's statement lines → auto-rules pre-fill
   the recurring charges (Google Ads, Facebook) → work through the remaining lines row by row
   (yellow rows first — those matched a highlight keyword) → save each row (orange → green) →
   copy the paste block into the PlusROI sheet.
2. **Rule curation**: when a recurring charge shows up ("EG: ADS9772464823 will auto assign it
   to Tuscany : Monthly Services with the description 'Google' and category 'Client Advertising
   Spend'" — verbatim transcript), add an auto-assign rule so next month it pre-fills; add
   highlight keywords (e.g. "UBER") for charges that are *sometimes* billable so they don't get
   skipped.
3. **Retroactive rule application**: after adding rules mid-batch, click "Re-apply rules" to
   run them against the still-unassigned lines of the current batch (explicitly requested
   2026-05-25).

## Complete behavior

### Navigation & landing

- **B1.** Top nav shows a "Tools" hover-dropdown, **owner-only** (hidden for non-owners).
  Submenu lists each tool; clicking the top-level item goes to `/tools`.
- **B2.** `/tools` renders an owner-gated card grid (`repeat(auto-fill, minmax(20rem, 1fr))`)
  with currently one card linking to `/tools/cc-expenses`, titled "Bill Through Credit Card
  Expenses". Non-owners get an inline `<p class="error">Owner only.</p>` (no redirect).
- **B3.** All four routes (`/tools`, `/tools/cc-expenses`, `/tools/cc-expenses/[id]`,
  `/tools/cc-expenses/rules`) do the same server-side gate: resolve org via
  `getAppOrg(supabase)` (missing → "Bowden Works organization not found."), resolve
  `getEffectiveUser(supabase, org.id)`, then require `canTransfer(eu)`; failure renders the
  inline "Owner only." error page.

### Batch list — `/tools/cc-expenses` (`page.tsx`)

- **B4.** Lists `cc_expense_batches` for the org ordered `created_at` DESC. Empty state:
  "No batches yet."
- **B5.** Table columns: Name (link to `/tools/cc-expenses/<id>`) · Lines · Assigned ·
  Total · Created · delete.
- **B6.** Per-batch aggregates come from ONE query of `cc_expense_lines` selecting
  `batch_id, project_id, amount_usd` with `.in('batch_id', ids)`, folded in JS:
  `lines` = row count; `assigned` = rows with non-null `project_id`; `total` =
  Σ `Number(amount_usd ?? 0)` — **includes unassigned lines**.
- **B7.** The Assigned cell appends `(<Math.round(assigned/lines*100)>%)` when `lines > 0`.
  Total renders via `fmtUsd`. Created shows `created_at.slice(0, 10)` (date part).
- **B8.** "+ New batch" is a link to `?new=1` (`scroll={false}`), hidden while the form is
  open. The inline panel has one required text field `name` (placeholder
  "e.g. April 2026 PlusROI", autoFocus), a submit button "Create batch", and a Cancel link
  back to `/tools/cc-expenses`.
- **B9.** Flash messages come from query params: `?error=` renders `<p class="error">`,
  `?info=` renders `<p class="info">` (same pattern on all three pages).
- **B10.** `createCcBatch(formData)`: requires trimmed `name` ("Batch name is required.");
  inserts `{ org_id, name, created_by: eu?.real_user_id ?? null }`; on success revalidates
  `/tools/cc-expenses` and redirects to `/tools/cc-expenses/<new id>`. Permission failure:
  "Only an owner can create expense batches."
- **B11.** `deleteCcBatch(formData)`: deletes by `.eq('id', id)` — lines removed via FK
  `ON DELETE CASCADE`; redirects to `/tools/cc-expenses?info=Batch+deleted.`. The only guard
  is the `DeleteForm` browser confirm:
  `Delete batch "<name>" and all its lines? Cannot be undone.`

### Statement paste parsing (`lib/cc-expenses.ts` — pure, no I/O)

- **B12.** **Accepted line format** (tab-separated, greedy column slots, extra trailing tabs
  ignored — `cols[5+]` never read):
  ```
  {date}\t{description}\t{debit amount}\t{credit amount}\t{running balance}\t
  ```
  Real example: `May 20, 2026\tFACEBK *N7VKLP9RX2\t$7.15\t\t$3,856.56\t`
  (column 4 — credit — is usually empty).
- **B13.** `parseExpenseDate(raw)` → `YYYY-MM-DD | null`. Trim; empty → null. ISO passthrough
  on `/^(\d{4})-(\d{2})-(\d{2})$/`. Long format on
  `/^([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})$/` — month name case-insensitive against the
  `MONTHS` map (full names + abbreviations: jan, feb, mar, apr, may, jun, jul, aug,
  **sep/sept**, oct, nov, dec; optional trailing period, comma optional). Validation: day
  1–31, year 1900–2999 — **no per-month day check** ("Feb 31" passes; see edge cases). Output
  is zero-padded.
- **B14.** `parseCurrency(raw)` → `number | null`. Trim, strip a single **leading** `$`, strip
  ALL commas; empty → null; `Number(...)`; non-finite → null; rounds to cents via
  `Math.round(n * 100) / 100`. Sign quirk: `-5` → −5 survives; `$-5.00` → −5; `-$5.00` → null
  (`$` isn't leading).
- **B15.** `parseCcLine(raw)` → `ParsedCcLine | null`
  (`{ raw_line, expense_date, description, amount_usd, balance_usd }`). Blank/whitespace-only
  → null. Otherwise split on `\t`, trim every column; `cols[0]` → date, `cols[1]` →
  description (empty → null), `cols[2]` → debit, `cols[3]` → credit, `cols[4]` → balance.
- **B16.** `amount_usd = debit ?? credit ?? null` — **debit wins if both set**; a credit is
  used as an alternate amount with **no sign flip** (Phase-1 decision: both positive).
- **B17.** **Partial parses keep the row**: any non-blank line returns a `ParsedCcLine` even
  if every field is null — the row is inserted and shown so the user can see what failed and
  fix/delete it. `raw_line` always preserves the verbatim input for audit/re-parse.
- **B18.** `parseCcPaste(input)` splits on `/\r?\n/` (CRLF-safe), parses each line, drops
  nulls (blank lines), preserves order.

### Paste ingestion + auto-assign — `appendCcLines(formData)` (`actions.ts`)

- **B19.** Detail page has a paste panel: textarea `paste` (6 rows, monospace) + hidden
  `batch_id` + "Add lines" button. Instruction copy: "Tab-separated columns: Date ·
  Description · Amount · (Credit) · Balance. Trailing tabs are tolerated. Pastes multiple
  times — each paste appends." Placeholder shows two real example lines.
- **B20.** Guards, in order: missing `batch_id` → "Missing batch id."; blank paste → "Paste
  some lines first."; zero parsed lines → "No usable lines found in the paste." Permission
  failure: "Only an owner can add expense lines."
- **B21.** `line_index` continues from the batch's current max (read `line_index` order DESC
  limit 1 maybeSingle; `startIdx = (max ?? -1) + 1`) — **appends preserve paste order across
  multiple pastes**. No dedupe: re-pasting the same lines creates duplicates.
- **B22.** All org `cc_auto_rules` are loaded ONCE (select
  `id, keyword, project_id, assignment_description, category`, ordered **`created_at` DESC**)
  and each parsed line's description is matched in JS via `findAutoRuleMatch`.
- **B23.** `findAutoRuleMatch(description, rules)`: case-insensitive **substring**
  (`String.includes`) of each rule's keyword against the description; **first match in list
  order wins** — with the created_at-DESC ordering that means **the newest matching rule
  wins**. Null/empty description never matches; blank keywords skipped. Returns the whole
  rule.
- **B24.** Insert rows are
  `{ batch_id, line_index: startIdx + i, raw_line, expense_date, description, amount_usd,
  balance_usd, project_id: match?.project_id ?? null, assignment_description:
  match?.assignment_description ?? null, category: match?.category ?? null, auto_assigned:
  match != null }` — one bulk insert.
- **B25.** Success flash: `Added <n> lines (<k> auto-assigned).` when k > 0, else
  `Added <n> lines.` — redirect back to the batch page with `?info=`.

### Line assignment — `updateCcLine` / `deleteCcLine`

- **B26.** Each line row has an inline form (per-row Save): `SearchableCombobox`
  `project_id` (options = org projects **that have a client**, labeled
  `` `${client.name} : ${p.name}` `` per ADR #042, sorted by label localeCompare; value =
  `projects.id`; placeholder "search projects…"), text `assignment_description` (placeholder
  "e.g. Google"), text `category` with a per-row `<datalist id="cc-cats-<lineId>">`
  (placeholder "e.g. Client Advertising Spend"), hidden `id` + `batch_id`.
- **B27.** Category autocomplete options = distinct non-null `category` values across ALL the
  org's CC lines (query `cc_expense_lines` select
  `category, batch:cc_expense_batches!inner(org_id)` filtered `.eq('batch.org_id', org.id)`,
  dedup + sort). First use of a category must be typed in full; later uses autocomplete.
- **B28.** `updateCcLine(formData)`: fields trimmed, empty → null; updates
  `{ project_id, assignment_description, category, auto_assigned: false }` by `.eq('id', id)`
  only (org scoping via RLS). **Every save flips `auto_assigned` to false** — saving IS
  confirming (orange → green). No success redirect — the action completes inline and
  `revalidatePath` refreshes the page. Permission failure: "Only an owner can edit expense
  lines." No validation that the chosen project belongs to the org (create-rule does
  validate; this doesn't).
- **B29.** Clearing the project and saving makes the line plain-unassigned again (renders
  neutral, or yellow if a highlight keyword matches; it re-becomes eligible for
  Re-apply rules).
- **B30.** `deleteCcLine(formData)`: requires `id` + `batch_id`; deletes `.eq('id', id)`;
  revalidates the batch page, no redirect. `DeleteForm` label `🗑️`, confirm "Delete this
  line? Cannot be undone." Purpose: remove rows that parsed wrong or aren't billable.

### Highlight keywords + 3-color row state

- **B31.** `findHighlightMatch(description, keywords)`: case-insensitive substring contains
  against the line description; returns the **first keyword to hit in the order passed**
  (page loads `cc_highlight_keywords` ordered by keyword ASC) or null. Null/empty description
  never matches; blank keywords skipped. Matching happens **at render time** — purely visual,
  nothing stored.
- **B32.** **Row background color — exact priority order (assigned state dominates):**
  1. **GREEN `#d1fae5`** — `project_id` set AND `auto_assigned = false` → user-saved
     ("confirmed"). Highest priority: the user explicitly took ownership.
  2. **ORANGE `#fed7aa`** — `project_id` set AND `auto_assigned = true` → filled by a rule,
     not yet user-saved ("needs review").
  3. **YELLOW `#fef9c3`** — `project_id IS NULL` AND description matches a highlight keyword
     → "look at this; it's probably assignable." Yellow ONLY paints when no assignment color
     applies.
  4. No background otherwise.
  This priority was **deliberately flipped** on 2026-05-25 from yellow-first to green-first
  after rian caught the bug from a screenshot ("tuscany should be orange because it was auto
  assigned and plusroi overhead should be green because I've saved it") — old priority made
  assignment state invisible whenever a keyword like "GOOGLE" overlapped assigned rows.
- **B33.** A highlighted (yellow) row gets `title='Highlight match: "<keyword>"'` — hover
  reveals which keyword hit.
- **B34.** The lines-panel header carries a legend of the three colors via swatches:
  `#fef9c3` "highlight", `#fed7aa` "auto-matched", `#d1fae5` "saved".
- **B35.** `auto_assigned` lifecycle: TRUE when set by a rule (at paste time or via
  Re-apply); FALSE on any `updateCcLine` save; column default FALSE (so pre-Phase-2 lines
  render green).

### Re-apply rules — `reapplyRules(formData)`

- **B36.** Header button "Re-apply rules" (tooltip: "Scan unassigned lines in this batch
  against the current auto-assign rules. Assigned lines are not touched."). Permission
  failure: "Only an owner can re-apply rules."
- **B37.** Loads in parallel: all org rules (same select + created_at DESC order as paste
  time) and this batch's **unassigned** lines only (`.is('project_id', null)`, select
  `id, description`). **Assigned lines are never touched** — whether user-confirmed or
  auto-assigned by a previous run.
- **B38.** Early-exit flashes: no rules → "No auto-assign rules to apply."; no unassigned
  lines → "No unassigned lines to match."
- **B39.** Per matched line, a sequential UPDATE sets
  `{ project_id, assignment_description, category, auto_assigned: true }` (rows paint orange
  = needs review). Final flash: "No new matches found." or
  `Re-applied rules: <n> line<s> auto-assigned.` (singular/plural handled).

### Batch detail totals & rendering — `/tools/cc-expenses/[id]`

- **B40.** Batch fetched with `.eq('org_id', org.id).eq('id', id)` maybeSingle; missing →
  "Batch not found" page + back link.
- **B41.** Lines load with embedded `project:projects(name, client:clients(name))`, ordered
  `line_index` ASC. `project_label` = `` `${client.name} : ${project.name}` `` or null when
  either name is missing.
- **B42.** Header stat line: `<totalLines> lines · <assigned> assigned ·
  <fmtUsd(totalAmount)> total` — assigned = lines with `project_id`; totalAmount =
  Σ `amount_usd ?? 0` over ALL lines (unassigned included).
- **B43.** Cell rendering: Date muted/small, **null date renders a red `?`**
  (`color: #b91c1c`) signaling the export will skip the line; Description monospace, null →
  muted `—`; Amount via `fmtUsd`, null → `—`. Table empty state: "Paste lines above to get
  started."

### Billing paste block (export)

- **B44.** Built server-side from lines where ALL of: `project_id` set, `project_label`
  non-null, `amount_usd != null`, `expense_date != null`. Lines missing any of these are
  **silently skipped** (the destination sheet needs the date; unassigned lines can't be
  billed).
- **B45.** Client/project recovered by `project_label.split(' : ', 2)` with fallbacks
  `(no client)` / `(no project)`; `assignment_description ?? ''`; `category ?? ''`.
- **B46.** **Exact format** — `formatCcPasteBlock(lines)`: one row per line, 7 tab-separated
  columns, rows joined with `\n`, **no trailing newline**:
  ```
  {client} : {project}\t{assignment_description}\t{amount_usd.toFixed(2)}\t{expense_date}\t\tBowden Works\t{category}
  ```
  | # | Column | Value |
  |---|--------|-------|
  | 1 | client : project | `` `${client} : ${project}` `` |
  | 2 | description | the line's `assignment_description` (e.g. "Google") — replaces the invoice block's fixed "Bowden Works Team" |
  | 3 | amount | `toFixed(2)`, **no `$`** |
  | 4 | date | `expense_date` as stored (`YYYY-MM-DD`) |
  | 5 | (empty) | always empty — note the two consecutive tabs |
  | 6 | fixed | literal `Bowden Works` |
  | 7 | category | the line's `category` — replaces the invoice block's "Labour" |

  Same 7-column shape as the invoice paste block (`lib/invoices.ts > formatPasteBlock`) by
  design — both feed the same sheet family.
- **B47.** `CcPasteBlock` client component: panel titled "Paste block for the PlusROI sheet";
  caption "One line per assigned expense. Paste into the PlusROI sheet."; Copy-to-clipboard
  button flips to "✓ Copied" for 1500 ms; clipboard failures are swallowed — the `<pre>`
  (whiteSpace pre, horizontally scrollable) is the triple-click fallback. Empty text → muted
  "Assign at least one line to a project to populate the paste block."

### Rules management — `/tools/cc-expenses/rules`

- **B48.** Owner-only; searchParams `error`, `info`, `edit` (auto-rule id in edit mode).
  Subtitle claims rules "apply ONLY to newly-pasted lines — existing lines aren't
  retroactively touched" — **stale copy**: Re-apply rules IS a retroactive path for
  unassigned lines (known wart; fix the copy in the rebuild).
- **B49.** **Highlight keywords section**: list ordered keyword ASC; add form = single
  required text `keyword` (autoFocus, helper "(case-insensitive substring match against the
  line description)", placeholder "e.g. UBER"); rows have background `#fefce8` hinting the
  row color; columns Keyword (monospace) · Added (date part) · Delete (confirm
  `Delete highlight keyword "<keyword>"?`). **No edit** — delete + re-add (tiny rows; the
  table has no UPDATE RLS policy either). Empty: "No highlight keywords yet."
- **B50.** `createHighlightKeyword`: keyword trimmed + required ("Keyword is required.");
  insert `{ org_id, keyword }`; Postgres `23505` (unique
  `cc_highlight_keywords_org_kw_uq (org_id, lower(keyword))` — **case-insensitive unique**) →
  `"<keyword>" is already a highlight keyword.`; success flash
  `Added highlight keyword "<keyword>".` `deleteHighlightKeyword`: delete by
  `.eq('id', id).eq('org_id', org.id)`; revalidate only, no flash.
- **B51.** **Auto-assign rules section**: list ordered `created_at` DESC — the same order the
  matcher uses, so **display order == precedence order (newest first)**. Add form grid:
  Keyword (required, placeholder "e.g. ADS9772464823") · Project (`SearchableCombobox`,
  labeled "({client} : {project})") · Description (placeholder "e.g. Google") · Category
  (shared `<datalist id="cc-rule-cats">` sourced from distinct categories among **existing
  rules**, not lines) · "Add rule". Display columns: Keyword (monospace) · Project
  (`{client} : {project}` or muted "— (deleted?)" when the join is missing) · Description ·
  Category · Edit link + Delete (confirm `Delete rule for "<keyword>"?`). Empty: "No
  auto-assign rules yet."
- **B52.** `createAutoRule`: keyword required ("Keyword is required."); project required
  ("Pick a project."); description/category trimmed empty→null; **validates the project
  belongs to the org** (else "Selected project not found in this org."); `23505` (unique
  `cc_auto_rules_org_kw_uq (org_id, lower(keyword))`) →
  `A rule for "<keyword>" already exists.`; flash `Added auto-assign rule for "<keyword>".`
- **B53.** Edit mode via URL state `?edit=<rule_id>` (same pattern as /team): the row is
  replaced by a full-width pre-filled form (background `#fff8e1`) with Save
  (`updateAutoRule`) + Cancel link. `updateAutoRule`: same field rules; updates by
  `.eq('id', id).eq('org_id', org.id)`; **does NOT re-validate project-org membership**
  (create does — known inconsistency); `23505` same message; flash
  `Updated rule for "<keyword>".` `deleteAutoRule`: delete by id + org_id; revalidate only.
- **B54.** Rules-page action failures redirect to `/tools/cc-expenses/rules?error=<msg>`;
  successes to `?info=<msg>` (module-local `fail`/`ok` helpers). Batch-page actions use
  `fail(msg, retPath)` with retPath defaulting to `/tools/cc-expenses` and set to
  `/tools/cc-expenses/<batchId>` for line/batch-scoped actions.

## Data model

Exact DDL in `supabase/migrations/20260525000040_cc_expenses.sql` (batches + lines + RLS),
`20260525000050_cc_rules.sql` (keywords + rules + RLS), `20260525000060_cc_auto_assigned.sql`
(`auto_assigned` column).

### `cc_expense_batches`
| column | type | notes |
|---|---|---|
| id | uuid PK | `gen_random_uuid()` |
| org_id | uuid NOT NULL | FK → `organizations(id)` ON DELETE CASCADE |
| name | text NOT NULL | e.g. "April 2026 PlusROI" |
| created_by | uuid NULL | FK → `auth.users(id)` ON DELETE SET NULL; set to `eu.real_user_id` |
| created_at | timestamptz NOT NULL | default `now()` |

Index: `cc_expense_batches_org_idx (org_id)`.

### `cc_expense_lines`
| column | type | notes |
|---|---|---|
| id | uuid PK | |
| batch_id | uuid NOT NULL | FK → `cc_expense_batches(id)` **ON DELETE CASCADE** |
| line_index | integer NOT NULL | paste order within batch; appends continue from max |
| raw_line | text NOT NULL | verbatim pasted line, audit/re-parse |
| expense_date | date NULL | null = parser failed (red `?`, excluded from export) |
| description | text NULL | statement description; the matcher target |
| amount_usd | numeric(12,2) NULL | debit ?? credit, positive |
| balance_usd | numeric(12,2) NULL | running balance, display-only |
| project_id | uuid NULL | FK → `projects(id)` **ON DELETE SET NULL**; non-null == "assigned" |
| assignment_description | text NULL | export column 2 |
| category | text NULL | export column 7; free text |
| auto_assigned | boolean NOT NULL default false | TRUE = rule-filled, not yet user-saved (orange); any save flips FALSE |
| created_at | timestamptz NOT NULL | |

**No `org_id`** — org scoping goes through the batch (RLS + the category query both join
`cc_expense_batches!inner(org_id)`). Indexes: `cc_expense_lines_batch_idx (batch_id)`;
`cc_expense_lines_project_idx (project_id) WHERE project_id IS NOT NULL` (partial — "which
expenses hit this project?" lookups).

### `cc_highlight_keywords`
`id` uuid PK · `org_id` uuid NOT NULL FK organizations CASCADE · `keyword` text NOT NULL ·
`created_at`. **UNIQUE INDEX `cc_highlight_keywords_org_kw_uq (org_id, lower(keyword))`**
(case-insensitive uniqueness). Purely visual — never written to lines.

### `cc_auto_rules`
`id` uuid PK · `org_id` uuid NOT NULL FK organizations CASCADE · `keyword` text NOT NULL ·
`project_id` uuid **NOT NULL** FK → `projects(id)` **ON DELETE CASCADE** (deleting a project
silently drops its rules — see edge cases) · `assignment_description` text NULL ·
`category` text NULL · `created_at`. **UNIQUE INDEX `cc_auto_rules_org_kw_uq
(org_id, lower(keyword))`**; index `cc_auto_rules_project_idx (project_id)`. No `priority`
column — precedence is `created_at` DESC (newest wins), deliberate v1 tiebreak.

## Permissions

- **UI + server actions: owner-only across the board** via `canTransfer(eu)` from
  `lib/permissions.ts` — true iff `eu.org_role === 'owner'` OR (`eu.is_super_admin` AND NOT
  `eu.is_viewing_as`). Every page renders "Owner only." and every action fails with an
  "Only an owner can …" message for non-owners. Feature-doc rationale: "there's no use case
  for managers here yet."
- Org resolution: `getAppOrg(supabase)` (the single Bowden Works org); effective user (view-as
  aware): `getEffectiveUser(supabase, org.id)`.
- **RLS is broader than the UI gate**: all four tables allow any authenticated org member
  (`public.is_org_member(org_id)`) to select/insert/update/delete — batches and rules
  directly, lines via an EXISTS on their batch. Exception: `cc_highlight_keywords` has **no
  UPDATE policy** (select/insert/delete only). The owner-only restriction lives entirely in
  the app layer.
- Several mutations rely on RLS alone for org scoping (no explicit org filter):
  `updateCcLine`, `deleteCcLine`, `deleteCcBatch`, `appendCcLines`, `reapplyRules` filter by
  row/batch id only. `createAutoRule` validates project-org membership; `updateAutoRule` and
  `updateCcLine` do not. The rebuild should make these checks explicit (extraction flag #5).

## Edge cases & error behavior

- **Duplicate pastes duplicate lines** — no fingerprint dedupe (`date+description+amount` was
  a listed open consideration). Recovery: per-row 🗑️ delete.
- **Credits lose their sign** (B16): a statement refund pastes as a positive amount
  indistinguishable from a debit and would export as a positive bill-through. Known Phase-1
  wart; "sign-aware amounts" is an open consideration.
- **Loose date validation**: `parseExpenseDate` accepts "Feb 31, 2026" → `"2026-02-31"`;
  Postgres would reject the bulk insert of the whole paste with a DB error surfaced via the
  `?error=` flash. Unlikely from real statements, but the rebuild parser should validate
  calendar dates.
- **`' : '` is a load-bearing delimiter**: the export round-trips the combobox label through
  `project_label.split(' : ', 2)`; a client or project name containing `" : "` corrupts the
  split (JS `split(sep, 2)` truncates the remainder). Rebuild: carry client/project as
  separate fields end-to-end.
- **Client-less projects are un-assignable**: both comboboxes filter out projects without a
  client name. A line already pointing at such a project renders `project_label` null and is
  silently excluded from the export.
- **Project deletion**: `cc_expense_lines.project_id` is ON DELETE **SET NULL** (line reverts
  to unassigned), but `cc_auto_rules.project_id` is ON DELETE **CASCADE** (rule vanishes).
  `/projects/manage` delete-with-reassignment does NOT rewrite rule project_ids to the merge
  target — the cascade drops them. Acknowledged v1 gap ("probably fine for v1").
- **Rules-page rows with a broken project join** render "— (deleted?)" (defensive display;
  with the CASCADE this should be unreachable, kept anyway).
- **Concurrency**: `appendCcLines` reads max `line_index` then inserts without a transaction —
  two concurrent pastes could interleave indexes (no unique constraint, so no failure, just
  ordering oddity). `reapplyRules` issues sequential per-line UPDATEs (N round trips) —
  acceptable at single-owner scale; code comment suggests a SQL-function JOIN at thousands of
  lines.
- **Error transport is `redirect(...?error=<msg>)`** in every action (`fail()` helpers) —
  there are no thrown errors reaching the user; DB error messages pass through verbatim.
  Duplicate-key inserts are the one specially-handled DB error (23505 → friendly message).
- **No timed anything, no undo** — batch delete cascades irreversibly behind one
  browser confirm.

## UI notes

- Single-page-per-concern server-rendered pages with inline forms; per-row Save forms post a
  server action and rely on `revalidatePath` (full refresh — no optimistic UI). The
  `ExpenseLineRow` docstring claims Save "only fires when the user actually changes
  something" — **not implemented**; Save is always active and even a no-op save confirms the
  row (flips orange → green).
- The row component keeps `assignment_description`/`category` as controlled local state;
  project combobox is uncontrolled (`defaultId`).
- Color hexes are load-bearing UI vocabulary: yellow `#fef9c3`, orange `#fed7aa`, green
  `#d1fae5` (rows); `#fefce8` highlight-keyword table rows; `#fff8e1` rule-edit row.
- **Rian's taste (transcript §17), directly expressed in this feature:**
  - **Paste-block outputs over file downloads** — requested twice (invoice lines AND CC
    expenses). Keep the copy-button + `<pre>` fallback pattern.
  - **Searchable comboboxes "similar to a google sheet with data validation"** — the
    `SearchableCombobox` with `{client} : {project}` labels is exactly this; keep
    type-to-filter.
  - **Color as state with priority semantics** — he specified "highlight in yellow, and auto
    match in orange and save in green" and personally caught the priority bug from a
    screenshot. The green > orange > yellow order is a rian-verified requirement, not an
    implementation detail.
  - Sensible defaults, quick links ("Last Month's PlusROI"-style chips), compact rows,
    progressive disclosure (the `?new=1` inline form, `?edit=<id>` inline editing).
  - Dislikes: page jumping to top on inline edit (`scroll={false}` on toggles is deliberate),
    edits that silently don't save, actions with no progress indicator (the
    full-page-reload-per-save here is a known perceived-slowness source), important info in
    small grey font.

## Rebuild deltas

Per `docs/rebuild/00-vision-and-scope.md` (R1 parity list includes CC expenses),
`02-domain-model.md` (§`cc_expense_batches` + mapping table), `04-architecture.md`,
`07-judgment-calls.md` §15:

- **Carries over as-is in R1** — same table names, tenant-scoped (`org_id` → `tenant_id`
  chain). The paste→parse→assign→rules→export workflow is unchanged; the paste-block export
  survives until the R4 money engine eliminates the PlusROI sheet (07-judgment-calls §15).
- **Lines gain `invoice_line_id`** (02-domain-model): a CC line attached to a non-void
  invoice becomes immutable — same lock invariant as time entries. Bill-through eventually
  becomes `invoice_lines` of kind `expense` instead of a sheet paste.
- **Hard-coded literals become tenant settings** (04-architecture "Per-tenant
  configuration"): the fixed `Bowden Works` column-6 literal (and paste-block column values
  generally) move to tenant config — no business strings in code.
- **RLS deleted** — authz moves to the Python service layer (`scope()`/`can()` choke point);
  the implicit RLS-only org scoping flagged above becomes explicit checks.
- **UI**: `SearchableCombobox` + `<datalist>` become the single `EntityPicker` primitive; the
  saved/auto-matched/highlight color trio becomes semantic design tokens in
  `styles/tokens.css`; row saves get optimistic updates via TanStack Query (no full reload);
  zero inline styles.
- **Fix the warts while porting**: separate client/project fields in the export path (kill
  the `' : '` split), sign-aware or flagged credits, calendar-valid dates, dedupe fingerprint
  at paste time, explicit rule `priority` column (or keep created_at-DESC but document it),
  update the stale rules-page subtitle, re-validate project-org on rule update.

## Test specs

Existing unit tests live in `tests/cc-expenses.test.ts` (all ✅, IDs verified); CCE-A-* were
specified in the feature doc but never automated (🟡). New IDs below (CCE-A-006+, CCE-V-*,
CCE-B-*) cover previously untested behaviors.

- T-CCE-D-001: given "May 20, 2026", when parseExpenseDate, then "2026-05-20".
- T-CCE-D-002: given "2026-05-20", when parseExpenseDate, then passes through unchanged.
- T-CCE-D-003: given "mAy 20 2026" (mixed case, no comma), when parsed, then "2026-05-20".
- T-CCE-D-004: given garbage ("hello", "13/13/13"), when parsed, then null.
- T-CCE-D-005: given "May 5, 2026", when parsed, then day zero-padded → "2026-05-05".
- T-CCE-D-006 (new): given "Sept. 3, 2026" (abbrev + period), when parsed, then "2026-09-03".
- T-CCE-D-007 (new, documents wart): given "Feb 31, 2026", when parsed, then "2026-02-31" is returned (no calendar validation) — rebuild SHOULD return null instead.
- T-CCE-C-001: given "$3,856.56", when parseCurrency, then 3856.56.
- T-CCE-C-002: given "" or "abc", when parseCurrency, then null.
- T-CCE-C-003: given "7.155", when parseCurrency, then rounds to 7.16 (2 decimals).
- T-CCE-L-001: given `May 20, 2026\tFACEBK *N7VKLP9RX2\t$7.15\t\t$3,856.56\t` (trailing tab), when parseCcLine, then all five fields parse.
- T-CCE-L-002: given a line with blank debit and credit "$25.00", when parsed, then amount_usd = 25.00 (positive — credit used as alt amount).
- T-CCE-L-003: given an empty/whitespace line, when parsed, then null.
- T-CCE-L-004: given any non-blank line, when parsed, then raw_line preserves the verbatim input.
- T-CCE-L-005: given an unparseable date but valid amount, when parsed, then expense_date null AND other fields kept (partial parse keeps the row).
- T-CCE-L-006 (new): given both debit and credit set, when parsed, then debit wins.
- T-CCE-P-001: given multi-line paste with blank lines, when parseCcPaste, then blanks skipped, order preserved.
- T-CCE-P-002: given CRLF line endings, when parseCcPaste, then parses identically to LF.
- T-CCE-H-001: given description containing a keyword, when findHighlightMatch, then that keyword returned.
- T-CCE-H-002: given case-mismatched keyword ("uber" vs "UBER RIDE"), when matched, then hit (case-insensitive).
- T-CCE-H-003: given no match / null / empty description, when matched, then null.
- T-CCE-H-004: given a description matching two keywords, when matched, then first-in-list wins.
- T-CCE-H-005: given an empty-string keyword in the list, when matched, then it is skipped.
- T-CCE-R-001: given a matching rule, when findAutoRuleMatch, then the whole rule (project_id + assignment_description + category) returned.
- T-CCE-R-002: given case-mismatched keyword, when matched, then hit.
- T-CCE-R-003: given no matching rule, then null.
- T-CCE-R-004: given two matching rules, then first in passed order wins (caller passes created_at DESC → newest wins).
- T-CCE-R-005: given null/empty description, then null.
- T-CCE-X-001: given empty input, when formatCcPasteBlock, then "".
- T-CCE-X-002: given one assigned line, when formatted, then exactly 7 tab-separated columns: `{client} : {project}`, assignment_description, amount, date, "", "Bowden Works", category.
- T-CCE-X-003: given amount 750, when formatted, then "750.00" — two decimals, no `$`.
- T-CCE-X-004: given two lines, when formatted, then joined with `\n`, no trailing newline.
- T-CCE-A-001: given a batch with 3 lines, when a second paste of 2 lines is added, then line_index continues 3,4 and display order matches paste order.
- T-CCE-A-002: given two rows, when one row is Saved, then only that row's fields change.
- T-CCE-A-003: given a batch with lines, when deleteCcBatch, then lines are gone (FK cascade).
- T-CCE-A-004: given a rule whose keyword matches a pasted line, when appendCcLines, then the line inserts with the rule's project/description/category and auto_assigned=true, and the flash reads "Added N lines (K auto-assigned).".
- T-CCE-A-005: given existing lines and a newly created rule, when the rule is saved, then existing lines are untouched (rules apply at paste/Re-apply only).
- T-CCE-A-006 (new): given unassigned lines and a matching rule, when reapplyRules, then those lines get the rule's fields with auto_assigned=true and flash "Re-applied rules: N lines auto-assigned.".
- T-CCE-A-007 (new): given a batch where every line is assigned, when reapplyRules, then flash "No unassigned lines to match." and nothing changes.
- T-CCE-A-008 (new): given zero rules, when reapplyRules, then flash "No auto-assign rules to apply.".
- T-CCE-A-009 (new): given an auto-assigned (orange) line, when reapplyRules runs again, then the line is NOT touched (project_id already set).
- T-CCE-A-010 (new): given an auto-assigned line, when updateCcLine (even with unchanged values), then auto_assigned=false.
- T-CCE-A-011 (new): given a paste with only blank/whitespace lines, when appendCcLines, then error "No usable lines found in the paste.".
- T-CCE-A-012 (new): given an empty paste field, when appendCcLines, then error "Paste some lines first.".
- T-CCE-A-013 (new): given a duplicate keyword differing only in case, when createHighlightKeyword / createAutoRule, then 23505 → the friendly "already exists" message (unique on lower(keyword)).
- T-CCE-A-014 (new): given a project from another org, when createAutoRule, then "Selected project not found in this org.".
- T-CCE-A-015 (new): given a non-owner (org_role != owner, or super-admin in view-as), when any cc-expenses page or action, then "Owner only." page / "Only an owner can …" error.
- T-CCE-V-001 (new): given project_id set and auto_assigned=false, when a row renders, then background green #d1fae5.
- T-CCE-V-002 (new): given project_id set and auto_assigned=true, then orange #fed7aa — even if a highlight keyword also matches (assigned beats highlight).
- T-CCE-V-003 (new): given project_id null and a highlight-keyword match, then yellow #fef9c3 with title `Highlight match: "<keyword>"`.
- T-CCE-V-004 (new): given project_id null and no match, then no background.
- T-CCE-V-005 (new): given a null expense_date, when the row renders, then a red `?` shows in the Date cell.
- T-CCE-B-001 (new): given a batch of 4 lines with 2 assigned, when the batch list renders, then Assigned shows "2 (50%)" and Total sums ALL lines' amounts.
- T-CCE-B-002 (new): given lines missing expense_date or amount or project, when the paste block builds, then those lines are excluded; remaining lines export.
- T-CCE-B-003 (new): given zero exportable lines, when the paste-block panel renders, then "Assign at least one line to a project to populate the paste block.".
