# CC pass-through expenses

**Status:** in production (M7, slice A)
**Lives at:** `/cc-expenses`, `/cc-expenses/$batchId`, `/cc-expenses/rules`
and `app/services/cc/**` · `app/routers/cc.py` ·
`frontend/src/features/cc/**` · `scripts/ts_cc_*.mjs`
**Spec (parity bar):**
`/srv/apps/work/docs/rebuild/01-current-system/features/cc-expenses.md`
(behaviors B1–B54; test IDs CCE-*)

## Summary

The PlusROI bill-through helper. Rian pastes a raw credit-card statement
into a named **batch**, assigns each line to a client **project** plus a
free-text description and category (by hand or via keyword **auto-assign
rules**), and copies a tab-separated **paste block** into the PlusROI
sheet. Highlight keywords tint probably-billable rows; a 3-color row
scheme (green saved > orange auto-matched > yellow highlight) tracks
review state. Owner-only.

## Why

Rian, 2026-05-25: "I need to paste in items from my credit card
statement… assign some of those to a project… generate a tab-separated
paste block I can copy into another sheet." Recurring charges (Google
Ads, Facebook) should pre-fill via rules; charges that are *sometimes*
billable (e.g. UBER) get a highlight keyword so they aren't skipped.

## Behavior

Ported behavior-for-behavior from the parity spec; the highlights:

- **Batches** (`GET/POST /api/cc/batches`, `DELETE …/{id}`): list newest
  first with per-batch aggregates — `lines`, `assigned` (+ `%`), and
  `total` (sums ALL lines, unassigned included) — from ONE grouped query
  (B4–B7). Create needs a name; `created_by` = the REAL human. Delete
  cascades its lines (FK), guarded only by a browser confirm (B10–B11).
- **Statement parse** (`app/services/cc/parse.py`): a faithful port of
  the legacy `lib/cc-expenses.ts`. Tab-separated greedy columns
  date/description/debit/credit/balance; `amount = debit ?? credit` (no
  sign flip); partial parses keep the row so the user can fix it;
  `parse_currency` replicates JS `Math.round(n*100)/100` on IEEE doubles
  (B12–B18).
- **Paste ingestion** (`POST …/{id}/lines`): parse → auto-stamp matching
  rules → bulk-insert continuing `line_index` from the batch max. Flash:
  `Added N lines (K auto-assigned).` No dedupe (B19–B25).
- **Line assignment** (`PATCH /api/cc/lines/{id}`): project (EntityPicker,
  labeled `{client} : {project}`) + description + category. Every save
  flips `auto_assigned` FALSE — saving IS confirming (orange → green).
  Clearing the project unassigns (B26–B29). `DELETE …/{id}` removes a
  mis-parsed / non-billable row (B30).
- **3-color rows** (`frontend/src/features/cc/ccState.ts`, render-time):
  saved (green) > auto-matched (orange) > highlight (yellow) > none —
  assignment state dominates a keyword hit; yellow only paints an
  unassigned row whose description matches a keyword (title shows which).
  A null date renders a red `?` (excluded from export). Colors are design
  tokens (`styles/tokens.css`) (B31–B35, B43).
- **Re-apply rules** (`POST …/{id}/reapply`): matches the current rules
  against only the batch's unassigned lines (assigned rows never
  touched), newest-rule-wins; early-exit flashes for no-rules /
  no-unassigned (B36–B39).
- **Paste block export** (`GET …/{id}/paste-block`): 7 tab-separated
  columns per exportable line —
  `{client} : {project}` · description · `amount.toFixed(2)` · date ·
  (empty) · operator literal · category — joined with `\n`, no trailing
  newline. Lines missing project/label/amount/date are silently skipped.
  A verified copy button re-fetches before copy; the `<pre>` is the
  triple-click fallback (B44–B47).
- **Rules & keywords** (`GET /api/cc/rules`, keyword + rule CRUD): rules
  display newest-first (== precedence); each rule is keyword +
  project + description + category. Highlight keywords are keyword-ASC,
  add/delete only. Both are case-insensitive unique per tenant (B48–B54).

## Constraints & edge cases

- **Owner-only** everywhere (reads included): the API returns
  `403 "Owner only."` / `"Only an owner can …"`; the UI shows the same
  inline gate. RLS is gone — authz is the Python service choke point.
- **Empty vs whitespace paste** are distinct (T-CCE-A-011/012): a truly
  empty field → "Paste some lines first."; non-empty content that parses
  to nothing → "No usable lines found in the paste."
- **Rule precedence is `created_at` DESC** (newest wins) — no `priority`
  column; documented and pinned by tests. On Postgres this is
  microsecond-ordered; the SQLite unit tests stamp explicit times.
- **Project-tenant is re-validated on rule create AND update** (the
  legacy update skipped it) and on line assignment.
- **`invoice_line_id` is never written** — R1 bill-through is a stateless
  export; the invoice-lock wiring is future work.
- `numeric(12,2)` rejects an out-of-calendar date at insert only if the
  parser let one through (see the carried date wart below).

## Permissions

Owner-only across the board via `can_transfer` (`owner` OR super-admin
not-viewing-as) — the authz capability grid, same predicate the money
paths use. There is no manager use case yet (parity).

## Open considerations (carried warts — deliberately not fixed in R1)

Kept faithful so the byte-diff gate stays exact; each is a candidate fix:

- **Loose date validation.** `parse_expense_date("Feb 31, 2026")` returns
  `"2026-02-31"` (no per-month calendar check) — the legacy behavior. A
  future calendar-valid parser would return null (T-CCE-D-007). Real
  statements never carry impossible dates, so the gate is unaffected.
- **Credits lose their sign** (`amount = debit ?? credit`, both positive):
  a refund exports as a positive bill-through. "Sign-aware amounts" is a
  future consideration.
- **No paste dedupe:** re-pasting the same lines duplicates them; recovery
  is the per-row delete.
- **`parse_currency` micro-divergence:** JS `Number` accepts hex/octal
  literals that Python `float` rejects — impossible in a currency column,
  gate-verified irrelevant.

## Tests

- **Parser/export unit** (`tests/cc/test_cc_parse.py`): the CCE-* suite
  ported from the legacy `tests/cc-expenses.test.ts` —
  CCE-D-001…007, CCE-C-001…004, CCE-L-001…006, CCE-P-001/002,
  CCE-H-001…005, CCE-R-001…005, CCE-X-001…005.
- **Service semantics** (`tests/cc/test_cc_service.py`): CCE-A-001…015,
  CCE-B-001…003, plus create/delete/re-validate/permission behaviors.
- **Row-state (render-time)** (`frontend/src/features/cc/ccState.test.ts`):
  CCE-H-001…005 + CCE-V-001…004 (the 3-color priority).
- **HTTP layer** (`tests/cc/test_cc_router.py`): auth gate + owner-only +
  structured errors + a self-cleaning owner roundtrip.
- **THE byte-diff GATE** (`tests/cc/test_cc_parser_gate.py`): the 102 REAL
  migrated statement lines run through the Python parser are
  field-identical to the legacy `parseCcLine` (via `tsx`
  `scripts/ts_cc_parse.mjs`); every real batch's paste block is
  byte-identical to the legacy `formatCcPasteBlock`
  (`scripts/ts_cc_export.mjs`).
- **End-to-end** (`tests/cc/test_cc_e2e_gate.py`): create → paste →
  auto-rules → assign → export on a commit-safe clone of the REAL
  migrated data (M6 clone machinery).

## Changelog

- 2026-07-08 — M7 slice A: shipped CC pass-through expenses (batches,
  parse, auto-rules + re-apply, 3-color assignment, PlusROI paste-block
  export) on the M2 `cc_*` tables (no schema change). Parser + export
  byte-diffed against the legacy TS on 102 real lines. ADR #008.A.
