Files
cyclone/docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md
T

21 KiB

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 CASCADEremittances.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 CASCADEclaims.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:

{
  "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 <th> + <td> 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)".