diff --git a/docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md b/docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md new file mode 100644 index 0000000..398ae8f --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md @@ -0,0 +1,323 @@ +# Sub-project 7 — Per-Service-Line Adjustment Audit: Design Spec + +**Date:** 2026-06-20 +**Status:** Brainstorm complete — pending implementation +**Supersedes:** N/A +**Depends on:** SP2 (DB + reconciliation), SP4 (claim drawer), SP5 (live tail), SP6 (inbox) + +## 1. Goals & non-goals + +### Goals + +- Give every 835 CAS segment a home at the **service-line level** rather than the claim level — the per-line adjudication trail that real billers need to defend against payer short-pays. +- Run the **837 SV1 ↔ 835 SVC composite** match **eagerly** at 835 ingest time and persist the result, so a re-read of the drawer always returns the same matches the system made on day one (audit-stable). +- Surface the per-line accounting in the existing surfaces (ClaimDrawer `ServiceLinesTable`, RemitDrawer `CasAdjustmentsPanel`, Inbox `MatchedRemitCard`) without inventing new pages. +- Add a dedicated **Line Reconciliation** tab inside the ClaimDrawer that shows the 837 lines and 835 SVC composites side-by-side with match arrows and per-line CAS reason codes. +- Treat unmatched 835 lines as a **soft warning**: claim still posts to PAID/PARTIAL/RECEIVED, but the Inbox shows a "lines: 3/4 — 1 unmatched" badge and the drawer surfaces a per-line "no 837 match" note. + +### Non-goals + +- No ML / fuzzy matching. Strict equality on (procedure_code, modifiers, service_date, units). If the algorithm improves later, a re-migration is needed. +- No two-stage posting workflow. Auto-post on 835 ingest stays. +- No patient statement / AR ledger (deferred to a later SP). +- No bulk re-match UI. Matches are stable; an admin tool to recompute on demand is a follow-on if ever needed. +- No new write-off or appeal workflows (those are SP6 follow-ons, separate from line-level work). +- No write-off / balance / aging reports on top of the new tables. Those belong in a future reporting SP. + +## 2. User-facing behavior + +### 2.1 New "Line Reconciliation" tab in ClaimDrawer + +Renders alongside the existing sections (State History, Raw Segments). Content: + +- Two-column layout: left = "Billed (837)", right = "Adjudicated (835)". +- Each row is a service line, matched by `line_number` when a `LineReconciliation` row exists; unmatched 835 lines appear at the bottom of the right column with an oxblood accent rail and the reason "no 837 line matched". +- Under each row: a compact CAS panel listing every `ClaimAdjustment` for that line (`group_code · reason_code · amount`). +- Header shows the totals: `Billed $X · Paid $Y · Adjustments $Z` with a "matched 4 of 4 lines" or "matched 3 of 4 lines (1 unmatched)" line in amber. + +### 2.2 Augmented ServiceLinesTable + +The existing 5-column table (`#`, procedure, charge, units, date) grows two new columns at the right: + +- **Paid** — the 835 SVC composite's `payment` for the matched line, in mono with tabular-nums. Empty dash for lines with no 835 match. +- **Adjustments** — the sum of that line's CAS amounts. Empty dash when zero. + +No re-sorting; the table keeps line-number order. + +### 2.3 Per-line attribution in RemitDrawer's `CasAdjustmentsPanel` + +The existing CAS panel (currently a flat list of `group_code · reason_code · amount`) grows a leading **Line** column showing `proc · #N` (e.g. `99213 · #2`). Rows with `service_line_payment_id IS NULL` (claim-level CAS, applies to the whole CLP) cluster at the bottom under a "Claim-level adjustments" sub-heading. + +### 2.4 Inbox `MatchedRemitCard` badge + +The existing card (currently shows remit id + status + totalPaid) grows a small mono line at the bottom: + +- Green-tinted: `lines: N/N matched` (full match) +- Amber-tinted: `lines: N/M matched (M-N unmatched)` + +## 3. Data model + +### 3.1 New ORM table: `service_line_payments` + +One row per 835 SVC composite (loop 2110). Independent of claim matching — gets persisted before `reconcile.run()` decides what to do with it. + +| Column | Type | Notes | +| --------------------- | ----------------- | -------------------------------------------------------------------- | +| `id` | `Integer` PK auto | | +| `remittance_id` | `String(64)` FK | `ON DELETE CASCADE` → `remittances.id` | +| `line_number` | `Integer` | 1-based; matches `ServicePayment.line_number` in the parser | +| `procedure_qualifier` | `String(4)` | e.g. `HC` | +| `procedure_code` | `String(16)` | e.g. `99213` | +| `modifiers_json` | `JSONText` | JSON array of strings (SQLite/PG-portable; not a join table) | +| `charge` | `Numeric(12,2)` | the 835 SVC `charge` (may differ from the 837 charge) | +| `payment` | `Numeric(12,2)` | the 835 SVC `payment` | +| `units` | `Numeric(10,2)?` | | +| `unit_type` | `String(8)?` | | +| `service_date` | `Date?` | | +| `ref_benefit_plan` | `String(64)?` | REF*0K | +| `superseded_by_id` | `Integer?` FK | self-FK; set when a reversal remit re-pays this line (see §7.3) | + +Indexes: `(remittance_id)`, `(procedure_code, service_date)`. + +### 3.2 New ORM table: `line_reconciliations` + +One row per matched (or explicitly unmatched) 837 service line within a claim. Created during `reconcile.run()` after the claim↔remit match. + +| Column | Type | Notes | +| --------------------- | ---------------- | -------------------------------------------------------------------------------------- | +| `id` | `Integer` PK | | +| `claim_id` | `String(64)` FK | `ON DELETE CASCADE` → `claims.id` | +| `claim_service_line_id` | `Integer` FK? | nullable: `service_lines.id` (837 SV1). NULL means "no 837 line matched this 835 line". | +| `service_line_payment_id` | `Integer` FK? | nullable: `service_line_payments.id`. NULL means "no 835 line matched this 837 line". | +| `status` | `String(16)` | one of: `matched`, `unmatched_835_only`, `unmatched_837_only`, `superseded` | +| `match_score` | `Integer?` | reserved for future fuzzy matching; NULL for strict matches | +| `reconciled_at` | `DateTime tz` | set when the row is written | + +Indexes: `(claim_id)`, `(claim_service_line_id)`, `(service_line_payment_id)`. + +Strict rule: at least one of the two nullable FKs must be non-NULL. The application enforces this in `reconcile.run()`; the DB layer documents it but does not enforce it (SQLite doesn't easily support CHECK constraints across columns; PG would). Status maps to the (non-)nullness: + +- both FKs set → `matched` +- only `service_line_payment_id` set → `unmatched_835_only` (the 835 has a line the 837 doesn't) +- only `claim_service_line_id` set → `unmatched_837_only` (the 837 has a line the 835 didn't pay) +- both NULL → impossible (skip the row) + +### 3.3 Modified `cas_adjustments`: add `service_line_payment_id` + +Add a nullable FK column `service_line_payment_id` (Integer, FK → `service_line_payments.id`, `ON DELETE SET NULL`). When the parser sees a CAS segment that follows an SVC composite, the store sets this FK to the corresponding `ServiceLinePayment.id`. CAS segments at the CLP level (claim-level) get `NULL` here. + +### 3.4 Modified `service_lines` (837) — no change needed + +The existing `service_lines` table (already used by the 837 writer) carries `procedure_code`, `modifiers_json`, `service_date`, `units`, `unit_type` and the parent `claim_id`. That's all `LineReconciliation` needs. + +## 4. Matching algorithm + +### 4.1 `reconcile.match_service_lines(claim, remit) -> list[LineMatch]` + +Pure function, called from `reconcile.run()` after the claim↔remit match. Inputs are ORM-shaped protocols (no SQLAlchemy imports). Returns one `LineMatch` per *837 service line* in the claim (an 837 line that's never matched produces a `LineReconciliation` with `service_line_payment_id IS NULL`); one additional `LineMatch` per *835 SVC composite* that's never matched produces a `LineReconciliation` with `claim_service_line_id IS NULL`. + +### 4.2 Strict match criteria + +A 835 SVC composite matches a 837 SV1 line iff ALL of: + +- `procedure_code` exact match after uppercasing both sides (HCPCS codes are conventionally uppercase; the parser preserves whatever the 837 / 835 sent, so we normalize at match time). +- `modifiers` set-equal (order-independent; both empty counts as a match) +- `service_date` exact match (both NULL counts as a match) +- `units` exact match (both NULL counts as a match; numeric equality, not Decimal-string) + +### 4.3 Tie-breaker + +A 837 line is consumed by at most one 835 SVC composite. Iterate the 837 lines in `line_number` order, find the first 835 SVC composite not yet consumed that matches, pair them. If multiple SVC composites could match a single 837 line (rare; same code + date + units), the one with the smallest `line_number` wins. Unmatched SVC composites become `unmatched_835_only` rows. + +### 4.4 Insertion order in `reconcile.run()` + +After the existing claim↔remit match logic (which already updates `Claim.matched_remittance_id`), and BEFORE the CAS-aggregate recompute: + +1. For each successfully matched `claim, remit` pair: + - Collect 837 service lines for `claim` (already loaded in `run`). + - Collect 835 SVC composites for `remit` from the `service_line_payments` rows already persisted. + - Call `match_service_lines(claim_lines, remit_lines)`. + - For each `LineMatch`, insert a `LineReconciliation` row. + - For each `CasAdjustment` row of the remit, set `service_line_payment_id` based on the parser's recorded mapping (see §3.3 — the store passes a `svc_to_cas` map at insert time). +2. Compute `claim_level_adjustments` aggregate (sum of `CasAdjustment` rows where `service_line_payment_id IS NULL`); write to a new `Remittance.claim_level_adjustment_amount` column. + +## 5. API surface + +### 5.1 New endpoint: `GET /api/claims/{claim_id}/line-reconciliation` + +Returns the dedicated view for the new tab. Shape: + +```json +{ + "claim_id": "CLM-1", + "summary": { + "billed_total": "350.00", + "paid_total": "300.00", + "adjustment_total": "45.00", + "matched_lines": 4, + "total_lines": 5 + }, + "lines": [ + { + "claim_service_line": { "line_number": 1, "procedure_code": "99213", ... }, + "service_line_payment": { "line_number": 1, "procedure_code": "99213", "payment": "100.00", ... }, + "status": "matched", + "adjustments": [ + { "group_code": "CO", "reason_code": "45", "amount": "25.00" } + ] + }, + { + "claim_service_line": { "line_number": 5, "procedure_code": "90837", ... }, + "service_line_payment": null, + "status": "unmatched_837_only", + "adjustments": [] + }, + { + "claim_service_line": null, + "service_line_payment": { "line_number": 6, "procedure_code": "99214", ... }, + "status": "unmatched_835_only", + "adjustments": [ + { "group_code": "PR", "reason_code": "1", "amount": "20.00" } + ] + } + ] +} +``` + +### 5.2 Modified `GET /api/claims/{claim_id}` + +The existing payload (used by `ClaimDrawer`) gains a `lineReconciliation: LineSummary[]` field — a slimmer projection (line_number + status + paid + adjustments sum), enough to populate the new `ServiceLinesTable` columns without a second fetch. The dedicated `GET /api/claims/{claim_id}/line-reconciliation` is what the tab calls. + +### 5.3 Modified `GET /api/remittances/{remittance_id}` + +The existing payload gains: + +- `serviceLinePayments: ServiceLinePayment[]` (raw list) +- `claimLevelAdjustments: CasAdjustment[]` (CAS rows with `service_line_payment_id IS NULL`) + +### 5.4 Modified `GET /api/inbox/lanes` + +The `InboxClaimRow.matched_remittance` payload gains a `matched_lines: number` and `total_lines: number` so the badge in `MatchedRemitCard` can render without a second fetch. + +## 6. UI components + +### 6.1 `ServiceLinesTable` — new columns + +Two new `` + `` cells per row: **Paid** (mono, tabular-nums, right-aligned) and **Adjustments** (mono, tabular-nums, right-aligned). Empty cells render `—` in the dim ink color. The grid template gets two more columns. + +### 6.2 `LineReconciliationTab` (new component) + +`src/components/ClaimDrawer/LineReconciliationTab.tsx`. Two-column flex layout (`grid-cols-2`); left column header "Billed (837 SV1)", right column "Adjudicated (835 SVC)". Each line is a card with the procedure code as headline, billed amount on the left, paid amount on the right, and an inline CAS list under each. Unmatched 835 lines get an oxblood accent rail. Header summary card on top: billed/paid/adjustment totals + "matched N of M lines" line. + +### 6.3 `CasAdjustmentsPanel` — line column + +Existing flat list grows a leading **Line** column. New helper `formatLineReference(svc_payment: ServiceLinePayment | null): string` returns `proc · #N` or `—` for claim-level rows. Group separator above the claim-level cluster. + +### 6.4 `MatchedRemitCard` — line-count badge + +Existing card grows a footer line. Helper computes the badge color from `(matched_lines, total_lines)`. + +## 7. Error handling & edge cases + +### 7.1 Unmatched 835 lines (`unmatched_835_only`) + +The 835 SVC composite is persisted (the row exists), the `LineReconciliation` row is created with `claim_service_line_id = NULL`. The Inbox still shows the claim in its normal lane — soft warning, no blocking. The drawer surfaces the line under "no 837 line matched" with oxblood accent. The claim-level payment totals are unchanged: the unmatched 835 payment is still counted in `Remittance.total_paid` (the payer did send that money), but the per-line accounting shows it as orphaned. + +### 7.2 Unmatched 837 lines (`unmatched_837_only`) + +The 837 line is fine; the payer just didn't pay it. The `LineReconciliation` row is created with `service_line_payment_id = NULL`. The drawer surfaces it under "no 835 line adjudicated this line". The Inbox card's badge becomes amber ("N/M matched"). + +### 7.3 Reversals + +When a 835 has `is_reversal = true`, the existing claim-level reversal logic (`reconcile.apply_reversal`) flips the claim to `REVERSED`. For line-level: + +- For each `service_line_payment` row in the reversal remit, attempt to find the matching `service_line_payment` row in a prior (non-reversal) remit for the same claim. Match criteria identical to §4.2. +- If found, set the new row's `superseded_by_id` to the prior row's id, and update the prior row's `LineReconciliation.status` to `superseded`. +- If not found, the new row is still persisted; its `LineReconciliation` (if any) is `unmatched_835_only` with the comment "reversal for unrecorded original". + +### 7.4 CLP-level CAS adjustments + +Persisted as `CasAdjustment` rows with `service_line_payment_id = NULL`. Surfaced in the RemitDrawer under a separate "Claim-level adjustments" cluster. Counted in `Remittance.claim_level_adjustment_amount` (new column) but NOT in any per-line adjustment sum. + +### 7.5 Multi-line remits / split claims + +One remit can pay multiple claims (multiple CLP segments). One claim can be paid by multiple remits (unlikely but possible). The new tables don't prevent either; each `ServiceLinePayment` belongs to one `Remittance`, each `LineReconciliation` belongs to one `Claim`. A claim paid by two remits gets two `LineReconciliation` row sets, one per remit — that's correct, not a bug. + +### 7.6 Remit-level CAS without any service payment + +If a 835 has CLP-level CAS but no SVC composites (degenerate but allowed), every `CasAdjustment` row gets `service_line_payment_id = NULL`. The aggregate is the entire adjustment. No `LineReconciliation` rows are created. The drawer surfaces "no 835 service-line detail" for the whole claim. + +### 7.7 Empty `service_payments` from the parser + +Same as §7.6 — the parser produces an empty list, the loop in §4.4 is a no-op, no `LineReconciliation` rows are created, no soft warning (this is normal for CLP-level-only adjustments). + +### 7.8 Test isolation + +All new tests must use a fresh in-memory SQLite (`sqlite:///:memory:` or a `tmp_path` fixture) so the existing 8 SP5 stream-isolation flakes stay excluded. Don't import the SP5 fixture helpers from the new tests. + +## 8. Testing strategy + +### 8.1 Backend (pytest) + +- `tests/test_service_line_payments.py` — ORM + persistence: a sample 835 produces the expected `service_line_payments` rows, CAS rows point to them, claim-level CAS rows have NULL FK. +- `tests/test_line_reconciliation.py` — pure-function tests for `match_service_lines()`: + - exact match, all 4 criteria aligned + - procedure_code mismatch → both sides unmatched + - modifiers set-equal (order swapped) + - service_date mismatch + - units mismatch + - claim with more lines than remit + - remit with more lines than claim + - empty 837 lines (no `LineReconciliation` rows) + - empty 835 SVC composites (no `LineReconciliation` rows) +- `tests/test_reconcile_line_level.py` — integration: a full 837→835 round trip produces the expected `LineReconciliation` rows with the right statuses. Includes a reversal case. +- `tests/test_api_line_reconciliation.py` — endpoint tests: + - `GET /api/claims/{id}/line-reconciliation` returns the documented shape + - 404 for unknown claim + - The `lineReconciliation` field on the existing claim detail endpoint is the slim projection +- Existing test files updated where necessary: + - `tests/test_acks.py` — bump the migration-version assertion from 4 to 5 (this SP's only migration is 0005, which sets `user_version = 5`). + - `tests/test_db_models.py` — add `ServiceLinePayment` + `LineReconciliation` to the model count (current count + 2). + +### 8.2 Frontend (vitest + happy-dom) + +- `src/components/ClaimDrawer/ServiceLinesTable.test.tsx` — new columns render with the right values, empty cells render `—`. +- `src/components/ClaimDrawer/LineReconciliationTab.test.tsx` — three render modes: full match, partial match (one unmatched 837), unmatched 835 line visible. +- `src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx` — line column populates from `service_line_payment`, claim-level cluster separated. +- `src/components/ClaimDrawer/MatchedRemitCard.test.tsx` — line-count badge renders green for full match, amber for partial. +- Existing tests for `useClaimDetail` and `useRemitDetail` — update the mock fixtures to include the new fields; assert the hook returns the new shape. + +### 8.3 Coverage targets + +- Backend: new lines ≥ 95% in `cyclone.reconcile` (the match function is pure), `cyclone.store` (the persistence loop), `cyclone.api` (the new endpoint). +- Frontend: existing thresholds; new components covered by tests in 8.2. + +## 9. Migration & rollout + +### 9.1 Migration `0005_line_reconciliation.sql` + +Single file under `backend/src/cyclone/migrations/`. Bumps `PRAGMA user_version` from 4 to 5. Contains: + +- `CREATE TABLE service_line_payments (...)` with all columns from §3.1 +- `CREATE TABLE line_reconciliations (...)` with all columns from §3.2 +- `ALTER TABLE cas_adjustments ADD COLUMN service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL` (SQLite: separate statement, see prior SP6 T2 lessons) +- `ALTER TABLE remittances ADD COLUMN claim_level_adjustment_amount NUMERIC(12,2) NOT NULL DEFAULT 0` +- `CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id)` +- `CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id)` +- `CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id)` + +### 9.2 ORM model additions + +`backend/src/cyclone/db.py` — add the `ServiceLinePayment` and `LineReconciliation` classes alongside the existing models. Add the `service_line_payment_id` FK to `CasAdjustment` and the `claim_level_adjustment_amount` column to `Remittance`. + +### 9.3 No backfill needed + +No prior SP persisted per-line data, so there's nothing to backfill. Existing `cas_adjustments` rows have `service_line_payment_id = NULL` after the migration — that's the correct state (they're CLP-level). + +### 9.4 Frontend types + +Add `ServiceLinePayment`, `LineReconciliation`, `LineReconciliationSummary`, `LineReconciliationRow` to `src/types/index.ts`. Update `ClaimDetail`, `RemittanceDetail`, `InboxClaimRow` (in `inbox-api.ts`) with the new fields. + +### 9.5 Rollout + +One branch (`line-reconciliation`), one PR, 17-21 tasks (TDD throughout). Backend suite + frontend suite both green before merge. Manual smoke test against a fresh DB with one 837 + one 835 + one reversal 835; verify the drawer tab renders the expected 4/4 match, the badge says "lines: 4/4 matched", and a synthetic 837 with a line the 835 doesn't pay renders "lines: 3/4 matched (1 unmatched)".