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 `