# 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 `