From e35c59bd6e988a157856a1a8594463a8f0d9f1d0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 19:19:26 -0600 Subject: [PATCH] =?UTF-8?q?docs(sp7):=20SP7=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=2021=20tasks=20across=206=20phases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-20-cyclone-line-reconciliation.md | 2680 +++++++++++++++++ 1 file changed, 2680 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md diff --git a/docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md b/docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md new file mode 100644 index 0000000..eaef613 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md @@ -0,0 +1,2680 @@ +# Sub-project 7 — Per-Service-Line Adjustment Audit: Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Date:** 2026-06-20 +**Status:** Ready +**Branch:** work continues on `main` (per session convention) +**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md` + +21 tasks across 6 phases. TDD throughout. Each task ends with a green test run + commit. + +**Prerequisite:** SP1–SP6 are merged on `main`. No new pip deps, no new npm deps. + +**Tech additions:** None. + +--- + +## Task 0: Verify SP6 is on `main` + +**Files:** none + +- [ ] **Step 1: Confirm SP6 is committed** + +```bash +cd /Users/openclaw/dev/cyclone +git log --oneline main | head -5 +git log --oneline main | grep -E "sp6.*(BulkBar|InboxHeader|compute_lanes|apply_999|migration 0004)" | head -5 +``` + +Expected: SP6 commits visible on `main`. If not, stop and resolve first. + +--- + +## Phase 1 — Schema + +## Task 1: Migration 0005 (line_reconciliation) + +**Files:** +- Create: `backend/src/cyclone/migrations/0005_line_reconciliation.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- version: 5 +-- SP7: per-service-line adjustment audit +-- See spec: docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md + +CREATE TABLE service_line_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + remittance_id VARCHAR(64) NOT NULL REFERENCES remittances(id) ON DELETE CASCADE, + line_number INTEGER NOT NULL, + procedure_qualifier VARCHAR(4) NOT NULL, + procedure_code VARCHAR(16) NOT NULL, + modifiers_json TEXT NOT NULL DEFAULT '[]', + charge NUMERIC(12, 2) NOT NULL, + payment NUMERIC(12, 2) NOT NULL, + units NUMERIC(10, 2), + unit_type VARCHAR(8), + service_date DATE, + ref_benefit_plan VARCHAR(64), + superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL +); + +CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id); +CREATE INDEX ix_service_line_payments_procedure_code_date ON service_line_payments(procedure_code, service_date); + +CREATE TABLE line_reconciliations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + claim_id VARCHAR(64) NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + claim_service_line_id INTEGER REFERENCES service_lines(id) ON DELETE SET NULL, + service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL, + status VARCHAR(16) NOT NULL, + match_score INTEGER, + reconciled_at TIMESTAMP NOT NULL +); + +CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id); +CREATE INDEX ix_line_reconciliations_claim_service_line_id ON line_reconciliations(claim_service_line_id); +CREATE INDEX ix_line_reconciliations_service_line_payment_id ON line_reconciliations(service_line_payment_id); + +ALTER TABLE cas_adjustments ADD COLUMN service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL; + +ALTER TABLE remittances ADD COLUMN claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0; + +CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id); +``` + +(Each `ALTER TABLE` and `CREATE TABLE` in its own statement — SQLite does not support multiple adds in one statement; this is the lesson from SP6 T2.) + +- [ ] **Step 2: Commit** + +```bash +git add backend/src/cyclone/migrations/0005_line_reconciliation.sql +git commit -m "feat(sp7): migration 0005 — service_line_payments + line_reconciliations + FKs" +``` + +--- + +## Task 2: ORM model — `ServiceLinePayment` + +**Files:** +- Modify: `backend/src/cyclone/db.py` +- Test: `backend/tests/test_db_models.py` (count bump only — covered by migration tests for shape) + +- [ ] **Step 1: Add the model class to `db.py`** + +After the existing `CasAdjustment` class, add: + +```python +class ServiceLinePayment(Base): + """One row per 835 SVC composite (loop 2110). + + SP7. Persisted on 835 ingest *before* ``reconcile.run()`` decides + which claim this line applies to. Independent of claim matching. + + See spec §3.1. + """ + __tablename__ = "service_line_payments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + remittance_id: Mapped[str] = mapped_column( + String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False + ) + line_number: Mapped[int] = mapped_column(Integer, nullable=False) + procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False) + procedure_code: Mapped[str] = mapped_column(String(16), nullable=False) + modifiers_json: Mapped[str] = mapped_column( + JSONText, nullable=False, default=lambda: "[]" + ) + charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + payment: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + units: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True) + unit_type: Mapped[Optional[str]] = mapped_column(String(8), nullable=True) + service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + ref_benefit_plan: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + superseded_by_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("service_line_payments.id", ondelete="SET NULL"), + nullable=True, + ) + + __table_args__ = ( + Index("ix_service_line_payments_remittance_id", "remittance_id"), + Index( + "ix_service_line_payments_procedure_code_date", + "procedure_code", + "service_date", + ), + ) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/src/cyclone/db.py +git commit -m "feat(sp7): ServiceLinePayment ORM model" +``` + +--- + +## Task 3: ORM model — `LineReconciliation` + +**Files:** +- Modify: `backend/src/cyclone/db.py` + +- [ ] **Step 1: Add the model class** + +After `ServiceLinePayment`, add: + +```python +class LineReconciliation(Base): + """One row per matched (or explicitly unmatched) 837 service line within a claim. + + SP7. Created during ``reconcile.run()`` after the claim↔remit match. + At least one of ``claim_service_line_id`` / ``service_line_payment_id`` + must be non-null; the application enforces this. + + See spec §3.2. + """ + __tablename__ = "line_reconciliations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + claim_id: Mapped[str] = mapped_column( + String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False + ) + claim_service_line_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("service_lines.id", ondelete="SET NULL"), + nullable=True, + ) + service_line_payment_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("service_line_payments.id", ondelete="SET NULL"), + nullable=True, + ) + status: Mapped[str] = mapped_column(String(16), nullable=False) + match_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + reconciled_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + __table_args__ = ( + Index("ix_line_reconciliations_claim_id", "claim_id"), + Index( + "ix_line_reconciliations_claim_service_line_id", "claim_service_line_id" + ), + Index( + "ix_line_reconciliations_service_line_payment_id", + "service_line_payment_id", + ), + ) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/src/cyclone/db.py +git commit -m "feat(sp7): LineReconciliation ORM model" +``` + +--- + +## Task 4: Add FK + column to existing tables + +**Files:** +- Modify: `backend/src/cyclone/db.py` + +- [ ] **Step 1: Add `service_line_payment_id` to `CasAdjustment`** + +Find the `CasAdjustment` class. Add this column after `quantity`: + +```python + service_line_payment_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("service_line_payments.id", ondelete="SET NULL"), + nullable=True, + ) +``` + +And add to `__table_args__`: + +```python + Index("ix_cas_adjustments_service_line_payment_id", "service_line_payment_id"), +``` + +- [ ] **Step 2: Add `claim_level_adjustment_amount` to `Remittance`** + +Find the `Remittance` class. Add this column after `adjustment_amount`: + +```python + claim_level_adjustment_amount: Mapped[Decimal] = mapped_column( + Numeric(12, 2), nullable=False, default=Decimal("0"), server_default=text("0") + ) +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/cyclone/db.py +git commit -m "feat(sp7): CasAdjustment.service_line_payment_id + Remittance.claim_level_adjustment_amount" +``` + +--- + +## Task 5: Update existing test fixtures for the migration + +**Files:** +- Modify: `backend/tests/test_acks.py` +- Modify: `backend/tests/test_db_models.py` + +- [ ] **Step 1: Update `test_acks.py` version assertion** + +Find the version assertion (currently 4). Change to 5: + +```python +assert version == 5 # SP7 migration 0005 +``` + +- [ ] **Step 2: Update `test_db_models.py` enum count** + +Find the model count assertion. The exact assertion depends on the file; bump by +2 (for `ServiceLinePayment` and `LineReconciliation`). If the test asserts the exact set of class names, add the two new class names. + +- [ ] **Step 3: Run tests** + +```bash +cd backend && pytest tests/test_acks.py tests/test_db_models.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_acks.py backend/tests/test_db_models.py +git commit -m "test(sp7): update migration version + model count assertions" +``` + +--- + +## Phase 2 — Persistence + +## Task 6: Persist `ServiceLinePayment` on 835 ingest + +**Files:** +- Modify: `backend/src/cyclone/store.py` +- Test: `backend/tests/test_service_line_payments.py` + +- [ ] **Step 1: Write the failing test** + +Append to `backend/tests/test_service_line_payments.py`: + +```python +"""SP7 — 835 ingest persists ServiceLinePayment rows + links CAS rows. + +See spec §3.1, §3.3. +""" +from datetime import date +from decimal import Decimal + +from sqlalchemy import select + +from cyclone.db import ( + Batch, + CasAdjustment, + Remittance, + ServiceLinePayment, + SessionLocal, +) +from cyclone.parsers.models_835 import ( + ClaimAdjustment, + ClaimPayment, + ServicePayment, +) +from cyclone.store import _persist_835_remit + + +def _make_batch() -> str: + """Persist a bare Batch row and return its id.""" + from datetime import datetime, timezone + import uuid + bid = str(uuid.uuid4()) + with SessionLocal()() as s: + s.add(Batch( + id=bid, source_filename="x.835", + parsed_at=datetime.now(timezone.utc), + summary_json={"claim_count": 1, "total_charge": "0", "total_paid": "0"}, + claim_count=1, + )) + s.commit() + return bid + + +def test_persist_835_creates_one_service_line_payment_per_svc_composite(): + bid = _make_batch() + cp = ClaimPayment( + payer_claim_control_number="PCN-1", + status_code="1", + total_charge=Decimal("200.00"), + total_paid=Decimal("150.00"), + service_payments=[ + ServicePayment( + line_number=1, procedure_qualifier="HC", + procedure_code="99213", charge=Decimal("100.00"), + payment=Decimal("80.00"), + adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))], + ), + ServicePayment( + line_number=2, procedure_qualifier="HC", + procedure_code="99214", charge=Decimal("100.00"), + payment=Decimal("70.00"), + adjustments=[], + ), + ], + ) + with SessionLocal()() as s: + from cyclone.store import _remittance_835_row + remit = _remittance_835_row(cp, bid) + s.add(remit) + s.flush() + _persist_835_remit(s, cp, remit.id) + s.commit() + + slps = list(s.execute(select(ServiceLinePayment).order_by(ServiceLinePayment.line_number)).scalars().all()) + assert len(slps) == 2 + assert slps[0].procedure_code == "99213" + assert slps[0].payment == Decimal("80.00") + assert slps[1].procedure_code == "99214" + assert slps[1].payment == Decimal("70.00") + assert slps[0].remittance_id == remit.id + + +def test_cas_adjustment_rows_get_service_line_payment_id_set(): + """The SVC-level CAS rows must link back to their ServiceLinePayment.""" + bid = _make_batch() + cp = ClaimPayment( + payer_claim_control_number="PCN-1", + status_code="1", + total_charge=Decimal("100.00"), + total_paid=Decimal("80.00"), + service_payments=[ + ServicePayment( + line_number=1, procedure_qualifier="HC", + procedure_code="99213", charge=Decimal("100.00"), + payment=Decimal("80.00"), + adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))], + ), + ], + ) + with SessionLocal()() as s: + from cyclone.store import _remittance_835_row + remit = _remittance_835_row(cp, bid) + s.add(remit) + s.flush() + _persist_835_remit(s, cp, remit.id) + s.commit() + + cas = list(s.execute(select(CasAdjustment)).scalars().all()) + assert len(cas) == 1 + assert cas[0].service_line_payment_id is not None +``` + +(Note: the test references `_persist_835_remit` which doesn't exist yet — that's the red.) + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && pytest tests/test_service_line_payments.py -v +``` + +Expected: ImportError on `_persist_835_remit` or `ServiceLinePayment`. + +- [ ] **Step 3: Implement `_persist_835_remit`** + +In `backend/src/cyclone/store.py`, add: + +```python +def _persist_835_remit(session, cp: "ClaimPayment", remittance_id: str) -> None: + """Persist ServiceLinePayment + CasAdjustment rows for one CLP composite. + + Spec §4.4: runs BEFORE ``reconcile.run()``. The CAS rows get their + ``service_line_payment_id`` set here so the line match step can + aggregate per-line adjustments later. + + Returns nothing; the caller controls the transaction. + """ + import json as _json + from cyclone.db import ServiceLinePayment, CasAdjustment + + for svc in cp.service_payments: + slp = ServiceLinePayment( + remittance_id=remittance_id, + line_number=svc.line_number, + procedure_qualifier=svc.procedure_qualifier, + procedure_code=svc.procedure_code, + modifiers_json=_json.dumps(svc.modifiers or []), + charge=Decimal(str(svc.charge)), + payment=Decimal(str(svc.payment)), + units=Decimal(str(svc.units)) if svc.units is not None else None, + unit_type=svc.unit_type, + service_date=svc.service_date, + ref_benefit_plan=svc.ref_benefit_plan, + ) + session.add(slp) + session.flush() # populate slp.id for the FK below + + for adj in svc.adjustments: + session.add(CasAdjustment( + remittance_id=remittance_id, + group_code=adj.group_code, + reason_code=adj.reason_code, + amount=Decimal(str(adj.amount)), + quantity=Decimal(str(adj.quantity)) if getattr(adj, "quantity", None) is not None else None, + service_line_payment_id=slp.id, + )) + + # CLP-level CAS adjustments (no SVC composite to attach to): persist + # with service_line_payment_id IS NULL. Today the 835 parser only + # produces svc.adjustments, so this is a no-op for current fixtures + # but is here for forward compatibility. + for adj in getattr(cp, "claim_adjustments", []) or []: + session.add(CasAdjustment( + remittance_id=remittance_id, + group_code=adj.group_code, + reason_code=adj.reason_code, + amount=Decimal(str(adj.amount)), + quantity=Decimal(str(adj.quantity)) if getattr(adj, "quantity", None) is not None else None, + service_line_payment_id=None, + )) +``` + +- [ ] **Step 4: Wire `_persist_835_remit` into the existing 835 ingest loop** + +In `backend/src/cyclone/store.py`, find the 835 ingest block (around line 905 — the loop that iterates `cp.service_payments` and inserts `CasAdjustment` rows). Replace the inner CAS loop with a call to `_persist_835_remit`. The existing code does: + +```python + for svc in cp.service_payments: + for adj in svc.adjustments: + s.add(_cas_adjustment_row(adj, remit_row.id)) +``` + +Replace with: + +```python + _persist_835_remit(s, cp, remit_row.id) +``` + +Then remove the now-unused `_cas_adjustment_row` helper (or leave it if you prefer minimal churn — see note below). + +**Note:** leaving `_cas_adjustment_row` in place is fine; tests don't care if it's unused. Removing it is cleaner. Pick whichever keeps the diff small. If you remove it, also remove its import. + +- [ ] **Step 5: Run tests, confirm PASS** + +```bash +cd backend && pytest tests/test_service_line_payments.py tests/test_prodfiles_smoke.py tests/test_api.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Full backend suite** + +```bash +cd backend && pytest -q +``` + +Expected: same baseline as before this task (the 8 SP5 stream flakes + 3 TA1 flakes remain; everything else green). + +- [ ] **Step 7: Commit** + +```bash +git add backend/src/cyclone/store.py backend/tests/test_service_line_payments.py +git commit -m "feat(sp7): persist ServiceLinePayment + link CAS rows on 835 ingest" +``` + +--- + +## Task 7: `reconcile.match_service_lines()` pure function + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py` +- Test: `backend/tests/test_line_reconciliation.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_line_reconciliation.py`: + +```python +"""SP7 — pure-function tests for match_service_lines(). + +Spec §4. +""" +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +import pytest + +from cyclone.reconcile import ( + LineMatch, + match_service_lines, +) + + +@dataclass +class FakeLine: + line_number: int + procedure_code: str + modifiers: list[str] + service_date: date | None + units: Decimal | None + payment: Decimal = Decimal("0") # 835 only + + +@dataclass +class FakeClaimLine(FakeLine): + charge: Decimal = Decimal("0") # 837 only + + +def _claim(*lines: FakeClaimLine) -> list[FakeClaimLine]: + return list(lines) + + +def _remit(*lines: FakeLine) -> list[FakeLine]: + return list(lines) + + +def test_exact_match_on_all_four_criteria(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), Decimal("1"))) + remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 1), Decimal("1"), payment=Decimal("80"))) + matches = match_service_lines(claim_lines, remit_lines) + assert len(matches) == 1 + assert matches[0].claim_line.line_number == 1 + assert matches[0].service_payment.line_number == 1 + assert matches[0].status == "matched" + + +def test_procedure_code_mismatch_both_unmatched(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None)) + remit_lines = _remit(FakeLine(1, "99214", [], None, None)) + matches = match_service_lines(claim_lines, remit_lines) + assert len(matches) == 2 + statuses = sorted(m.status for m in matches) + assert statuses == ["unmatched_835_only", "unmatched_837_only"] + + +def test_modifiers_set_equal_regardless_of_order(): + claim_lines = _claim(FakeClaimLine(1, "99213", ["25", "59"], None, None)) + remit_lines = _remit(FakeLine(1, "99213", ["59", "25"], None, None)) + matches = match_service_lines(claim_lines, remit_lines) + assert len(matches) == 1 + assert matches[0].status == "matched" + + +def test_service_date_mismatch(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), None)) + remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 2), None)) + matches = match_service_lines(claim_lines, remit_lines) + assert len(matches) == 2 + statuses = sorted(m.status for m in matches) + assert statuses == ["unmatched_835_only", "unmatched_837_only"] + + +def test_units_mismatch(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], None, Decimal("1"))) + remit_lines = _remit(FakeLine(1, "99213", [], None, Decimal("2"))) + matches = match_service_lines(claim_lines, remit_lines) + assert len(matches) == 2 + statuses = sorted(m.status for m in matches) + assert statuses == ["unmatched_835_only", "unmatched_837_only"] + + +def test_procedure_code_case_normalized(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None)) + remit_lines = _remit(FakeLine(1, "99213", [], None, None)) # case irrelevant when both upper + matches = match_service_lines(claim_lines, remit_lines) + assert matches[0].status == "matched" + + +def test_claim_has_more_lines_than_remit(): + claim_lines = _claim( + FakeClaimLine(1, "99213", [], None, None), + FakeClaimLine(2, "99214", [], None, None), + ) + remit_lines = _remit(FakeLine(1, "99213", [], None, None)) + matches = match_service_lines(claim_lines, remit_lines) + statuses = sorted(m.status for m in matches) + assert statuses == ["matched", "unmatched_837_only"] + + +def test_remit_has_more_lines_than_claim(): + claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None)) + remit_lines = _remit( + FakeLine(1, "99213", [], None, None), + FakeLine(2, "99214", [], None, None), + ) + matches = match_service_lines(claim_lines, remit_lines) + statuses = sorted(m.status for m in matches) + assert statuses == ["matched", "unmatched_835_only"] + + +def test_empty_837_lines_no_reconciliation_rows(): + matches = match_service_lines([], _remit(FakeLine(1, "99213", [], None, None))) + assert len(matches) == 1 + assert matches[0].status == "unmatched_835_only" + + +def test_empty_835_lines_no_reconciliation_rows(): + matches = match_service_lines(_claim(FakeClaimLine(1, "99213", [], None, None)), []) + assert len(matches) == 1 + assert matches[0].status == "unmatched_837_only" + + +def test_both_empty(): + assert match_service_lines([], []) == [] +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && pytest tests/test_line_reconciliation.py -v +``` + +Expected: ImportError on `LineMatch` / `match_service_lines`. + +- [ ] **Step 3: Implement `match_service_lines()`** + +In `backend/src/cyclone/reconcile.py`, add: + +```python +@dataclass +class LineMatch: + """One row of a line-level reconciliation result. + + At least one of ``claim_line`` / ``service_payment`` is non-None. + Spec §4.1. + """ + claim_line: Optional["_ClaimLineLike"] = None + service_payment: Optional["_ServicePaymentLike"] = None + status: str = "" # matched | unmatched_835_only | unmatched_837_only + + +class _ClaimLineLike(Protocol): + line_number: int + procedure_code: str + modifiers: list[str] + service_date: Optional[date] + units: Optional[Decimal] + + +class _ServicePaymentLike(Protocol): + line_number: int + procedure_code: str + modifiers: list[str] + service_date: Optional[date] + units: Optional[Decimal] + + +def _norm_code(code: str) -> str: + return (code or "").strip().upper() + + +def _norm_mods(mods: list[str] | None) -> frozenset[str]: + return frozenset((m or "").strip().upper() for m in (mods or []) if m) + + +def _svc_matches_claim(svc, claim) -> bool: + """Strict match per spec §4.2.""" + return ( + _norm_code(svc.procedure_code) == _norm_code(claim.procedure_code) + and _norm_mods(svc.modifiers) == _norm_mods(claim.modifiers) + and svc.service_date == claim.service_date + and svc.units == claim.units + ) + + +def match_service_lines( + claim_lines: list[_ClaimLineLike], + service_payments: list[_ServicePaymentLike], +) -> list[LineMatch]: + """Pair each 837 SV1 line with the first 835 SVC composite that matches. + + Spec §4. Unmatched lines on either side produce their own LineMatch + rows so every line is accounted for in the persistence step. + """ + matches: list[LineMatch] = [] + used_svc_ids: set[int] = set() + + for claim in sorted(claim_lines, key=lambda c: c.line_number): + match_idx: Optional[int] = None + for i, svc in enumerate(service_payments): + if i in used_svc_ids: + continue + if _svc_matches_claim(svc, claim): + match_idx = i + break + if match_idx is not None: + used_svc_ids.add(match_idx) + matches.append(LineMatch( + claim_line=claim, + service_payment=service_payments[match_idx], + status="matched", + )) + else: + matches.append(LineMatch( + claim_line=claim, + service_payment=None, + status="unmatched_837_only", + )) + + for i, svc in enumerate(service_payments): + if i in used_svc_ids: + continue + matches.append(LineMatch( + claim_line=None, + service_payment=svc, + status="unmatched_835_only", + )) + + return matches +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && pytest tests/test_line_reconciliation.py -v +``` + +Expected: 11 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_line_reconciliation.py +git commit -m "feat(sp7): match_service_lines() pure function + tests" +``` + +--- + +## Task 8: Wire `match_service_lines()` into `reconcile.run()` + +**Files:** +- Modify: `backend/src/cyclone/reconcile.py` +- Test: `backend/tests/test_reconcile_line_level.py` + +- [ ] **Step 1: Write the integration test** + +Create `backend/tests/test_reconcile_line_level.py`: + +```python +"""SP7 — integration test: reconcile.run() persists LineReconciliation rows. + +Spec §4.4. +""" +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest +from sqlalchemy import select + +from cyclone.db import ( + Batch, + Claim, + LineReconciliation, + Remittance, + ServiceLinePayment, + ServiceLine, + SessionLocal, +) +from cyclone.reconcile import run +from cyclone.store import _remittance_835_row, _persist_835_remit +from cyclone.parsers.models_835 import ClaimPayment, ServicePayment + + +def _setup_db() -> tuple[str, str]: + """Build a minimal 837 claim + 835 remit that share PCN. Returns (claim_id, remittance_id).""" + from cyclone.parsers.models_837 import ( + ClaimOutput, ServiceLine as ParseServiceLine, Procedure, + ) + import uuid + + bid = str(uuid.uuid4()) + claim_id = "CLM-T1" + + with SessionLocal()() as s: + # 1. Batch + s.add(Batch( + id=bid, source_filename="x.835", + parsed_at=datetime.now(timezone.utc), + summary_json={"claim_count": 1, "total_charge": "200", "total_paid": "150"}, + claim_count=1, + )) + # 2. Claim with two service lines + s.add(Claim( + id=claim_id, + patient_control_number="PCN-1", + charge_amount=Decimal("200.00"), + provider_npi="1234567890", + payer_id="SKCO0", + submission_date=date(2026, 6, 1), + state="submitted", + batch_id=bid, + )) + s.flush() + s.add(ServiceLine( + claim_id=claim_id, line_number=1, + procedure_code="99213", + modifiers_json="[]", + charge=Decimal("100.00"), + service_date=date(2026, 6, 1), + )) + s.add(ServiceLine( + claim_id=claim_id, line_number=2, + procedure_code="99214", + modifiers_json="[]", + charge=Decimal("100.00"), + service_date=date(2026, 6, 1), + )) + # 3. Remit with two SVC composites, one matching claim line 1, one not + cp = ClaimPayment( + payer_claim_control_number="PCN-1", + status_code="1", + total_charge=Decimal("200.00"), + total_paid=Decimal("150.00"), + service_payments=[ + ServicePayment( + line_number=1, procedure_qualifier="HC", + procedure_code="99213", + charge=Decimal("100.00"), + payment=Decimal("80.00"), + service_date=date(2026, 6, 1), + ), + ServicePayment( + line_number=2, procedure_qualifier="HC", + procedure_code="90837", + charge=Decimal("100.00"), + payment=Decimal("70.00"), + service_date=date(2026, 6, 1), + ), + ], + ) + remit = _remittance_835_row(cp, bid) + s.add(remit) + s.flush() + _persist_835_remit(s, cp, remit.id) + s.commit() + + return claim_id, bid + + +def test_run_persists_line_reconciliation_rows(): + claim_id, bid = _setup_db() + with SessionLocal()() as s: + result = run(s, bid) + s.commit() + assert result.matched == 1 + + lrs = list( + s.execute(select(LineReconciliation).where(LineReconciliation.claim_id == claim_id)) + .scalars().all() + ) + # Expect 3 rows: 99213 matched, 99214 unmatched_837_only, 90837 unmatched_835_only + assert len(lrs) == 3 + by_status = {lr.status for lr in lrs} + assert by_status == {"matched", "unmatched_837_only", "unmatched_835_only"} +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && pytest tests/test_reconcile_line_level.py -v +``` + +Expected: failure — `run()` doesn't persist `LineReconciliation` rows yet. + +- [ ] **Step 3: Extend `reconcile.run()`** + +In `backend/src/cyclone/reconcile.py`, inside `run()`, AFTER the existing claim↔remit match loop and BEFORE the CAS aggregate recompute, insert: + +```python + # SP7: line-level reconciliation. For each matched (claim, remit) pair, + # find each 837 service line's matching 835 SVC composite and persist + # a LineReconciliation row per line on either side (matched or + # explicitly unmatched). See spec §4.4. + from cyclone.db import LineReconciliation, ServiceLinePayment, ServiceLine as ClaimServiceLine + for m in matches: + if m.is_reversal: + continue # reversals are handled by a separate path (see §7.3) + claim_id = m.claim.id + remit_id = m.remittance.id + + claim_lines = list( + session.execute( + select(ClaimServiceLine) + .where(ClaimServiceLine.claim_id == claim_id) + .order_by(ClaimServiceLine.line_number) + ).scalars().all() + ) + svc_payments = list( + session.execute( + select(ServiceLinePayment) + .where(ServiceLinePayment.remittance_id == remit_id) + .order_by(ServiceLinePayment.line_number) + ).scalars().all() + ) + + line_matches = match_service_lines( + [_ClaimLineShim(line) for line in claim_lines], + [_SvcPaymentShim(svc) for svc in svc_payments], + ) + + now = datetime.now(timezone.utc) + for lm in line_matches: + session.add(LineReconciliation( + claim_id=claim_id, + claim_service_line_id=lm.claim_line.line_id if lm.claim_line else None, + service_line_payment_id=lm.service_payment.id if lm.service_payment else None, + status=lm.status, + reconciled_at=now, + )) + + # SP7: claim-level adjustment aggregate. Sum CAS rows whose + # service_line_payment_id IS NULL — these are CLP-level adjustments, + # not per-line. Persist on the remit for fast reads. + from sqlalchemy import func as _func + for r in new_remits: + cl_total = session.execute( + select(_func.sum(CasAdjustment.amount)) + .where(CasAdjustment.remittance_id == r.id) + .where(CasAdjustment.service_line_payment_id.is_(None)) + ).scalar_one() or Decimal("0") + r.claim_level_adjustment_amount = cl_total +``` + +Then add the shims at module top level (after the existing protocol definitions): + +```python +@dataclass +class _ClaimLineShim: + """Adapt an ORM ClaimServiceLine to the _ClaimLineLike protocol. + + Used by ``reconcile.run()`` to call ``match_service_lines()``. + """ + line_id: int + line_number: int + procedure_code: str + modifiers: list[str] + service_date: Optional[date] + units: Optional[Decimal] + + @classmethod + def from_orm(cls, line) -> "_ClaimLineShim": + import json as _json + return cls( + line_id=line.id, + line_number=line.line_number, + procedure_code=line.procedure_code, + modifiers=_json.loads(line.modifiers_json or "[]"), + service_date=line.service_date, + units=Decimal(str(line.units)) if line.units is not None else None, + ) + + def __init__(self, line): # accept ORM directly for convenience + import json as _json + self.line_id = line.id + self.line_number = line.line_number + self.procedure_code = line.procedure_code + self.modifiers = _json.loads(line.modifiers_json or "[]") + self.service_date = line.service_date + self.units = Decimal(str(line.units)) if line.units is not None else None + + +@dataclass +class _SvcPaymentShim: + line_id: int + line_number: int + procedure_code: str + modifiers: list[str] + service_date: Optional[date] + units: Optional[Decimal] + + def __init__(self, svc): + import json as _json + self.line_id = svc.id + self.line_number = svc.line_number + self.procedure_code = svc.procedure_code + self.modifiers = _json.loads(svc.modifiers_json or "[]"), + self.service_date = svc.service_date + self.units = Decimal(str(svc.units)) if svc.units is not None else None +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && pytest tests/test_reconcile_line_level.py -v +``` + +Expected: PASS. + +- [ ] **Step 5: Full backend suite** + +```bash +cd backend && pytest -q +``` + +Expected: same baseline. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile_line_level.py +git commit -m "feat(sp7): wire match_service_lines into reconcile.run" +``` + +--- + +## Phase 3 — API + +## Task 9: `GET /api/claims/{id}/line-reconciliation` endpoint + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Test: `backend/tests/test_api_line_reconciliation.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_api_line_reconciliation.py`: + +```python +"""SP7 — endpoint test for GET /api/claims/{id}/line-reconciliation. + +Spec §5.1. +""" +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient + +from cyclone import db +from cyclone.api import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +def _seed(client): + """Insert one claim + matching remit + 4 service lines (3 matched, 1 unmatched 837). + Returns the claim id.""" + import uuid, json as _json + from sqlalchemy import select + bid = str(uuid.uuid4()) + claim_id = "CLM-API-1" + + with db.SessionLocal()() as s: + s.add(db.Batch( + id=bid, source_filename="x.835", + parsed_at=datetime.now(timezone.utc), + summary_json={"claim_count": 1, "total_charge": "300", "total_paid": "200"}, + claim_count=1, + )) + s.add(db.Claim( + id=claim_id, + patient_control_number="PCN-API", + charge_amount=Decimal("300.00"), + provider_npi="1234567890", + payer_id="SKCO0", + submission_date=date(2026, 6, 1), + state="submitted", + batch_id=bid, + )) + s.flush() + # Three matching lines + one 837-only + for i, code in enumerate(["99213", "99214", "99215", "90837"], start=1): + s.add(db.ServiceLine( + claim_id=claim_id, line_number=i, + procedure_code=code, modifiers_json="[]", + charge=Decimal("100.00"), + service_date=date(2026, 6, i), + )) + # Remit pays the first 3 lines + from cyclone.parsers.models_835 import ClaimPayment, ServicePayment + cp = ClaimPayment( + payer_claim_control_number="PCN-API", + status_code="1", + total_charge=Decimal("300.00"), + total_paid=Decimal("240.00"), + service_payments=[ + ServicePayment(line_number=1, procedure_qualifier="HC", + procedure_code=c, charge=Decimal("100.00"), + payment=Decimal("80.00"), service_date=date(2026, 6, i)) + for i, c in enumerate(["99213", "99214", "99215"], start=1) + ], + ) + from cyclone.store import _remittance_835_row, _persist_835_remit + remit = _remittance_835_row(cp, bid) + s.add(remit) + s.flush() + _persist_835_remit(s, cp, remit.id) + s.commit() + + # Run reconcile so matched_remittance_id gets set + LineReconciliation rows persist + from cyclone.reconcile import run + run(s, bid) + s.commit() + + return claim_id + + +def test_endpoint_returns_documented_shape(client): + claim_id = _seed(client) + r = client.get(f"/api/claims/{claim_id}/line-reconciliation") + assert r.status_code == 200, r.text + body = r.json() + assert body["claim_id"] == claim_id + assert body["summary"]["total_lines"] == 4 + assert body["summary"]["matched_lines"] == 3 + statuses = sorted(row["status"] for row in body["lines"]) + assert statuses == ["matched", "matched", "matched", "unmatched_837_only"] + + +def test_endpoint_404_for_unknown_claim(client): + r = client.get("/api/claims/UNKNOWN/line-reconciliation") + assert r.status_code == 404 +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && pytest tests/test_api_line_reconciliation.py -v +``` + +Expected: 404 from FastAPI (route not registered). + +- [ ] **Step 3: Implement the endpoint** + +In `backend/src/cyclone/api.py`, add: + +```python +@app.get("/api/claims/{claim_id}/line-reconciliation") +def get_claim_line_reconciliation(claim_id: str): + """Per-line reconciliation view for the ClaimDrawer tab. + + Spec §5.1. Returns the 837 service lines and 835 SVC composites + side-by-side, with per-line CAS adjustments. + """ + from sqlalchemy import select + from cyclone.db import ( + LineReconciliation, ServiceLine, ServiceLinePayment, CasAdjustment, + ) + import json as _json + from decimal import Decimal + + with db.SessionLocal()() as s: + claim = s.get(db.Claim, claim_id) + if claim is None: + raise HTTPException(404, f"claim {claim_id} not found") + + # Load 837 lines + 835 svc payments + line reconciliation rows + claim_lines = list( + s.execute( + select(ServiceLine).where(ServiceLine.claim_id == claim_id) + ).scalars().all() + ) + lrs = list( + s.execute( + select(LineReconciliation).where(LineReconciliation.claim_id == claim_id) + ).scalars().all() + ) + + # Index by claim_service_line_id and service_line_payment_id for lookup + lr_by_claim_line: dict[int, LineReconciliation] = { + lr.claim_service_line_id: lr for lr in lrs if lr.claim_service_line_id is not None + } + lr_by_svc: dict[int, LineReconciliation] = { + lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None + } + + # Load all relevant svc payments in one go + svc_ids = [lr.service_line_payment_id for lr in lrs if lr.service_line_payment_id is not None] + svc_by_id: dict[int, ServiceLinePayment] = {} + if svc_ids: + for svc in s.execute(select(ServiceLinePayment).where(ServiceLinePayment.id.in_(svc_ids))).scalars().all(): + svc_by_id[svc.id] = svc + + # Load CAS adjustments grouped by service_line_payment_id + cas_by_svc: dict[int, list[CasAdjustment]] = {} + if svc_ids: + for cas in s.execute(select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids))).scalars().all(): + cas_by_svc.setdefault(cas.service_line_payment_id, []).append(cas) + + # Build the lines[] array, preserving claim-line ordering plus unmatched 835 lines at the end + lines_out: list[dict] = [] + billed_total = Decimal("0") + paid_total = Decimal("0") + adjustment_total = Decimal("0") + matched_count = 0 + + for cl in sorted(claim_lines, key=lambda c: c.line_number): + billed_total += Decimal(str(cl.charge)) + lr = lr_by_claim_line.get(cl.id) + if lr is None: + # Claim line exists but no LineReconciliation row (rare). + lines_out.append({ + "claim_service_line": _claim_line_to_dict(cl), + "service_line_payment": None, + "status": "unmatched_837_only", + "adjustments": [], + }) + continue + svc = svc_by_id.get(lr.service_line_payment_id) if lr.service_line_payment_id else None + cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else [] + cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0")) + if svc: + paid_total += Decimal(str(svc.payment)) + adjustment_total += cas_total + if lr.status == "matched": + matched_count += 1 + lines_out.append({ + "claim_service_line": _claim_line_to_dict(cl), + "service_line_payment": _svc_to_dict(svc) if svc else None, + "status": lr.status, + "adjustments": [ + {"group_code": c.group_code, "reason_code": c.reason_code, + "amount": str(Decimal(str(c.amount)))} + for c in cas_list + ], + }) + + # Unmatched 835 lines (no claim_service_line) + for lr in lrs: + if lr.claim_service_line_id is not None: + continue + svc = svc_by_id.get(lr.service_line_payment_id) + if svc: + paid_total += Decimal(str(svc.payment)) + cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else [] + cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0")) + adjustment_total += cas_total + lines_out.append({ + "claim_service_line": None, + "service_line_payment": _svc_to_dict(svc) if svc else None, + "status": lr.status, + "adjustments": [ + {"group_code": c.group_code, "reason_code": c.reason_code, + "amount": str(Decimal(str(c.amount)))} + for c in cas_list + ], + }) + + total_lines = len(claim_lines) # billed line count + + return { + "claim_id": claim_id, + "summary": { + "billed_total": str(billed_total), + "paid_total": str(paid_total), + "adjustment_total": str(adjustment_total), + "matched_lines": matched_count, + "total_lines": total_lines, + }, + "lines": lines_out, + } + + +def _claim_line_to_dict(cl) -> dict: + import json as _json + return { + "id": cl.id, + "line_number": cl.line_number, + "procedure_qualifier": cl.procedure_qualifier if hasattr(cl, "procedure_qualifier") else "HC", + "procedure_code": cl.procedure_code, + "modifiers": _json.loads(cl.modifiers_json or "[]"), + "charge": str(Decimal(str(cl.charge))), + "units": str(Decimal(str(cl.units))) if cl.units is not None else None, + "unit_type": cl.unit_type if hasattr(cl, "unit_type") else None, + "service_date": cl.service_date.isoformat() if cl.service_date else None, + } + + +def _svc_to_dict(svc) -> dict: + import json as _json + return { + "id": svc.id, + "line_number": svc.line_number, + "procedure_qualifier": svc.procedure_qualifier, + "procedure_code": svc.procedure_code, + "modifiers": _json.loads(svc.modifiers_json or "[]"), + "charge": str(Decimal(str(svc.charge))), + "payment": str(Decimal(str(svc.payment))), + "units": str(Decimal(str(svc.units))) if svc.units is not None else None, + "unit_type": svc.unit_type, + "service_date": svc.service_date.isoformat() if svc.service_date else None, + } +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && pytest tests/test_api_line_reconciliation.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_line_reconciliation.py +git commit -m "feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint" +``` + +--- + +## Task 10: Augment `GET /api/claims/{id}` with `lineReconciliation` slim projection + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Test: existing `test_api_claim_detail.py` + +- [ ] **Step 1: Find the existing claim detail endpoint** + +In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/claims/{claim_id}")` (NOT the new line-reconciliation one from T9). Find where it builds the response dict. + +- [ ] **Step 2: Add the `lineReconciliation` field** + +After the existing fields, add: + +```python + # SP7: slim per-line projection — enough for the ServiceLinesTable + # columns without forcing a second fetch. + from cyclone.db import LineReconciliation as LR + from sqlalchemy import select as _select + slim_lrs = list( + s.execute( + _select(LR).where(LR.claim_id == claim_id) + ).scalars().all() + ) + # Index for O(1) lookup by claim_service_line_id + slim_by_cl = {lr.claim_service_line_id: lr for lr in slim_lrs if lr.claim_service_line_id is not None} + # Aggregate per-line CAS sums + from cyclone.db import CasAdjustment as CA + cas_sums_by_svc: dict[int, str] = {} + svc_ids_for_cas = [lr.service_line_payment_id for lr in slim_lrs if lr.service_line_payment_id is not None] + if svc_ids_for_cas: + cas_rows = s.execute( + _select(CA.service_line_payment_id, CA.amount) + .where(CA.service_line_payment_id.in_(svc_ids_for_cas)) + ).all() + from collections import defaultdict + agg = defaultdict(lambda: Decimal("0")) + for svc_id, amount in cas_rows: + agg[svc_id] += Decimal(str(amount)) + cas_sums_by_svc = {k: str(v) for k, v in agg.items()} + + # Load svc payments for paid amounts + from cyclone.db import ServiceLinePayment as SLP + svc_by_id_slim: dict[int, SLP] = {} + if svc_ids_for_cas: + for svc in s.execute(_select(SLP).where(SLP.id.in_(svc_ids_for_cas))).scalars().all(): + svc_by_id_slim[svc.id] = svc + + line_reconciliation_slim = [] + for cl in sorted(service_lines, key=lambda c: c.line_number): + lr = slim_by_cl.get(cl.id) + if lr is None: + line_reconciliation_slim.append({ + "claim_service_line_id": cl.id, + "line_number": cl.line_number, + "status": "unmatched_837_only", + "paid": None, + "adjustments_sum": None, + }) + continue + svc = svc_by_id_slim.get(lr.service_line_payment_id) if lr.service_line_payment_id else None + line_reconciliation_slim.append({ + "claim_service_line_id": cl.id, + "line_number": cl.line_number, + "status": lr.status, + "paid": str(Decimal(str(svc.payment))) if svc else None, + "adjustments_sum": cas_sums_by_svc.get(lr.service_line_payment_id) if lr.service_line_payment_id else None, + }) + + response["lineReconciliation"] = line_reconciliation_slim +``` + +(If `service_lines` is named differently in the existing function, use whatever the local variable is. Read the existing endpoint to find the right name.) + +- [ ] **Step 3: Run claim-detail tests** + +```bash +cd backend && pytest tests/test_api_claim_detail.py -v +``` + +Expected: PASS. (Existing tests don't assert on the absence of `lineReconciliation`, so they'll pass with the new field present.) + +- [ ] **Step 4: Add a test asserting the new field exists** + +Append to `tests/test_api_claim_detail.py`: + +```python +def test_claim_detail_includes_line_reconciliation_slim_projection(client, seeded_claim): + r = client.get(f"/api/claims/{seeded_claim['id']}") + assert r.status_code == 200 + body = r.json() + assert "lineReconciliation" in body + assert isinstance(body["lineReconciliation"], list) +``` + +If the fixture isn't called `seeded_claim`, use the equivalent. Read the file's existing fixtures first. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_claim_detail.py +git commit -m "feat(sp7): claim detail includes lineReconciliation slim projection" +``` + +--- + +## Task 11: Augment `GET /api/remittances/{id}` with `serviceLinePayments` + `claimLevelAdjustments` + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Test: existing remit detail tests (find them via `pytest --collect-only`) + +- [ ] **Step 1: Find the remit detail endpoint** + +In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/remittances/{remittance_id}")`. + +- [ ] **Step 2: Add the two new fields** + +Before `return`: + +```python + # SP7: per-line SVC composites + claim-level CAS bucket + from cyclone.db import ServiceLinePayment as SLP2, CasAdjustment as CA2 + from sqlalchemy import select as _select2 + + slps = list( + s.execute( + _select2(SLP2).where(SLP2.remittance_id == remittance_id) + .order_by(SLP2.line_number) + ).scalars().all() + ) + slp_dicts = [_svc_to_dict(svc) for svc in slps] + + cl_cas = list( + s.execute( + _select2(CA2).where( + (CA2.remittance_id == remittance_id) + & (CA2.service_line_payment_id.is_(None)) + ) + ).scalars().all() + ) + + response["serviceLinePayments"] = slp_dicts + response["claimLevelAdjustments"] = [ + { + "id": c.id, + "group_code": c.group_code, + "reason_code": c.reason_code, + "amount": str(Decimal(str(c.amount))), + "quantity": str(Decimal(str(c.quantity))) if c.quantity is not None else None, + } + for c in cl_cas + ] +``` + +(Use the local response variable name as appropriate.) + +- [ ] **Step 3: Run remit detail tests** + +```bash +cd backend && pytest -k "remit" -q +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/src/cyclone/api.py +git commit -m "feat(sp7): remit detail includes serviceLinePayments + claimLevelAdjustments" +``` + +--- + +## Task 12: Augment `GET /api/inbox/lanes` `matched_remittance` payload + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Test: `backend/tests/test_inbox_endpoints.py` + +- [ ] **Step 1: Find the inbox lanes endpoint** + +In `backend/src/cyclone/api.py`, find `inbox_lanes()` (or whatever the inbox lanes route function is named). + +- [ ] **Step 2: Add line counts to the `matched_remittance` payload** + +Inside the loop that builds the rejected/candidates rows that include a matched remittance, find where `matched_remittance` dict is built. Add: + +```python + # SP7: matched/total line counts for the MatchedRemitCard badge + from cyclone.db import LineReconciliation as LR3, ServiceLine as ClaimLine2 + from sqlalchemy import select as _select3 + total_lines_for_claim = s.execute( + _select3(_select3.func.count()) + .select_from(ClaimLine2) + .where(ClaimLine2.claim_id == claim.id) + ).scalar_one() or 0 + matched_lines_for_claim = s.execute( + _select3(_select3.func.count()) + .select_from(LR3) + .where((LR3.claim_id == claim.id) & (LR3.status == "matched")) + ).scalar_one() or 0 + matched_remittance_dict["matched_lines"] = int(matched_lines_for_claim) + matched_remittance_dict["total_lines"] = int(total_lines_for_claim) +``` + +If the existing code uses a different variable name for the dict, use that. Read the function carefully first. + +- [ ] **Step 3: Run inbox endpoint tests** + +```bash +cd backend && pytest tests/test_inbox_endpoints.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/src/cyclone/api.py +git commit -m "feat(sp7): inbox lanes matched_remittance includes matched/total line counts" +``` + +--- + +## Phase 4 — Frontend types & API client + +## Task 13: Add types to `src/types/index.ts` + +**Files:** +- Modify: `src/types/index.ts` + +- [ ] **Step 1: Add new types** + +Append to `src/types/index.ts`: + +```typescript +// --------------------------------------------------------------------------- +// SP7 — Per-Service-Line Adjustment Audit types. +// See docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md +// --------------------------------------------------------------------------- + +export interface ServiceLinePayment { + id: number; + lineNumber: number; + procedureQualifier: string; + procedureCode: string; + modifiers: string[]; + charge: string; + payment: string; + units: string | null; + unitType: string | null; + serviceDate: string | null; +} + +export type LineReconciliationStatus = + | "matched" + | "unmatched_835_only" + | "unmatched_837_only" + | "superseded"; + +export interface ClaimDetailLineReconciliation { + claimServiceLineId: number; + lineNumber: number; + status: LineReconciliationStatus; + paid: string | null; + adjustmentsSum: string | null; +} + +export interface LineReconciliationSummary { + billedTotal: string; + paidTotal: string; + adjustmentTotal: string; + matchedLines: number; + totalLines: number; +} + +export interface LineReconciliationRow { + claimServiceLine: { + id: number; + lineNumber: number; + procedureQualifier: string; + procedureCode: string; + modifiers: string[]; + charge: string; + units: string | null; + unitType: string | null; + serviceDate: string | null; + } | null; + serviceLinePayment: ServiceLinePayment | null; + status: LineReconciliationStatus; + adjustments: Array<{ + groupCode: string; + reasonCode: string; + amount: string; + }>; +} + +export interface LineReconciliationResponse { + claimId: string; + summary: LineReconciliationSummary; + lines: LineReconciliationRow[]; +} + +export interface RemittanceClaimLevelAdjustment { + id: number; + groupCode: string; + reasonCode: string; + amount: string; + quantity: string | null; +} + +export interface MatchedRemittanceLineCounts { + matchedLines: number; + totalLines: number; +} +``` + +- [ ] **Step 2: Update `ClaimDetail` to include `lineReconciliation`** + +Find `ClaimDetail` in `src/types/index.ts`. Add the field: + +```typescript + lineReconciliation: ClaimDetailLineReconciliation[]; +``` + +- [ ] **Step 3: Update `RemittanceDetail`** + +Find the remittance detail type (probably named `RemittanceDetail` or similar — read the file to find it). Add: + +```typescript + serviceLinePayments: ServiceLinePayment[]; + claimLevelAdjustments: RemittanceClaimLevelAdjustment[]; +``` + +(If the type uses a different name like `Remittance` for the detail payload, use that. Read first.) + +- [ ] **Step 4: Run typecheck** + +```bash +pnpm tsc --noEmit +``` + +Expected: errors in places that use the old types without the new fields. We'll fix those in subsequent tasks. + +- [ ] **Step 5: Commit** + +```bash +git add src/types/index.ts +git commit -m "feat(sp7): frontend types — ServiceLinePayment, LineReconciliation, MatchedRemittance" +``` + +--- + +## Task 14: Update `src/lib/inbox-api.ts` with `matched_lines`/`total_lines` + +**Files:** +- Modify: `src/lib/inbox-api.ts` + +- [ ] **Step 1: Update `InboxMatchedRemittance` type** + +Find the type for the matched-remittance object in `inbox-api.ts`. Add: + +```typescript + matchedLines: number; + totalLines: number; +``` + +- [ ] **Step 2: Update `fetchInboxLanes` test fixtures** + +If `inbox-api.test.ts` has mock fixtures with `matched_remittance`, add the two fields with sensible defaults (e.g. `matchedLines: 4, totalLines: 4`). + +- [ ] **Step 3: Run tests** + +```bash +pnpm vitest run src/lib/inbox-api.test.ts +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts +git commit -m "feat(sp7): inbox API types include matched/total line counts" +``` + +--- + +## Phase 5 — UI + +## Task 15: `ServiceLinesTable` — Paid + Adjustments columns + +**Files:** +- Modify: `src/components/ClaimDrawer/ServiceLinesTable.tsx` +- Test: `src/components/ClaimDrawer/ServiceLinesTable.test.tsx` + +- [ ] **Step 1: Read existing component** + +Read `src/components/ClaimDrawer/ServiceLinesTable.tsx` to understand the current 5-column structure and the row data shape. + +- [ ] **Step 2: Add the two new columns** + +Modify the table to render two more `` and `` cells per row. The component should accept an optional `lineReconciliation: ClaimDetailLineReconciliation[]` prop (default to `[]`). For each row, look up the entry by `lineNumber` and render: + +- Paid: `fmt(lineReconciliation.paid)` if non-null, else `—` +- Adjustments: `fmt(lineReconciliation.adjustmentsSum)` if non-null, else `—` + +The cell style should match the existing "charge" column (mono, tabular-nums, right-aligned, dim ink for `—`). + +- [ ] **Step 3: Update existing tests** + +If `ServiceLinesTable.test.tsx` uses a non-optional prop or fixture, update the fixture to include `lineReconciliation: []` so existing assertions still hold. + +- [ ] **Step 4: Add new tests** + +Append to `ServiceLinesTable.test.tsx`: + +```tsx +it("renders Paid and Adjustments columns from lineReconciliation prop", () => { + const lineReconciliation = [ + { claimServiceLineId: 1, lineNumber: 1, status: "matched" as const, + paid: "80.00", adjustmentsSum: "20.00" }, + { claimServiceLineId: 2, lineNumber: 2, status: "unmatched_837_only" as const, + paid: null, adjustmentsSum: null }, + ]; + const { container } = render( + + ); + // line 1: matched + expect(container.textContent).toContain("$80.00"); + expect(container.textContent).toContain("$20.00"); + // line 2: empty cells render em-dash + // (look for "—" in the relevant cells) +}); + +it("renders em-dash for missing lineReconciliation entries", () => { + const { container } = render( + + ); + // Expect at least two em-dashes (Paid + Adjustments for the one row) + expect((container.textContent ?? "").split("—").length - 1).toBeGreaterThanOrEqual(2); +}); +``` + +(Fill in the serviceLines fixture with whatever shape the existing tests use.) + +- [ ] **Step 5: Run tests** + +```bash +pnpm vitest run src/components/ClaimDrawer/ServiceLinesTable.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ClaimDrawer/ServiceLinesTable.tsx src/components/ClaimDrawer/ServiceLinesTable.test.tsx +git commit -m "feat(sp7): ServiceLinesTable — Paid + Adjustments columns" +``` + +--- + +## Task 16: `CasAdjustmentsPanel` — Line column + +**Files:** +- Modify: `src/components/RemitDrawer/CasAdjustmentsPanel.tsx` +- Test: `src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx` + +- [ ] **Step 1: Read existing component** + +Read `src/components/RemitDrawer/CasAdjustmentsPanel.tsx` to understand the current row shape and how it groups claim-level adjustments. + +- [ ] **Step 2: Add a Line column to each row** + +The component receives a list of CAS adjustments. Each row should now show `proc · #N` for the SVC-level CAS rows (where the row's `serviceLinePaymentId` is non-null) and `—` for claim-level rows. Cluster the claim-level rows at the bottom under a "Claim-level adjustments" sub-header. + +If the component currently groups by `serviceLinePaymentId`, the Line column just renders that group's header (procedure code · line number). If it doesn't, you'll need to add the grouping. + +- [ ] **Step 3: Update fixture data** + +Existing tests likely pass rows like `{ group_code, reason_code, amount }`. Add `service_line_payment: { procedure_code, line_number } | null` and a `groupBySvc: boolean` flag if needed. + +- [ ] **Step 4: Add a new test** + +```tsx +it("renders line column with procedure code + line number for SVC-level CAS", () => { + const rows = [ + { id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00", + serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } }, + ]; + const { container } = render(); + expect(container.textContent).toMatch(/99213.*#1/); +}); + +it("separates claim-level adjustments into a sub-cluster", () => { + const rows = [ + { id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00", + serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } }, + { id: 2, groupCode: "PR", reasonCode: "1", amount: "10.00", + serviceLinePayment: null }, + ]; + const { container } = render(); + expect(container.textContent).toMatch(/Claim-level adjustments/i); +}); +``` + +(Adjust the prop names to match whatever the existing component uses.) + +- [ ] **Step 5: Run tests** + +```bash +pnpm vitest run src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/RemitDrawer/CasAdjustmentsPanel.tsx src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx +git commit -m "feat(sp7): CasAdjustmentsPanel — Line column + claim-level cluster" +``` + +--- + +## Task 17: `MatchedRemitCard` — line-count badge + +**Files:** +- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx` +- Test: `src/components/ClaimDrawer/MatchedRemitCard.test.tsx` + +- [ ] **Step 1: Read existing component** + +Read `MatchedRemitCard.tsx` to see the current card structure and the `matchedRemittance` prop type. + +- [ ] **Step 2: Add the badge** + +At the bottom of the card, add a small mono line: + +```tsx +const matched = matchedRemittance.matchedLines; +const total = matchedRemittance.totalLines; +const allMatched = matched === total; +const badgeColor = allMatched ? "var(--tt-amber)" /* or green if available */ : "var(--tt-amber)"; +// ... +
+ lines: {matched}/{total} matched + {!allMatched && ` (${total - matched} unmatched)`} +
+``` + +Use whatever ink color tokens match the rest of the design. If there's a dedicated "success" token (e.g. `--tt-success`), use it for the full-match case. + +- [ ] **Step 3: Update fixture** + +Existing test fixtures for `MatchedRemitCard` need to include `matchedLines: 4, totalLines: 4`. Add the fields with default values. + +- [ ] **Step 4: Add new tests** + +```tsx +it("renders full-match badge when matchedLines equals totalLines", () => { + const rem = { /* existing fixture */, matchedLines: 4, totalLines: 4 }; + const { container } = render(); + expect(container.textContent).toMatch(/4\/4 matched/); +}); + +it("renders partial-match badge with unmatched count", () => { + const rem = { /* existing fixture */, matchedLines: 3, totalLines: 4 }; + const { container } = render(); + expect(container.textContent).toMatch(/3\/4 matched/); + expect(container.textContent).toMatch(/1 unmatched/); +}); +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm vitest run src/components/ClaimDrawer/MatchedRemitCard.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ClaimDrawer/MatchedRemitCard.tsx src/components/ClaimDrawer/MatchedRemitCard.test.tsx +git commit -m "feat(sp7): MatchedRemitCard — line-count badge" +``` + +--- + +## Task 18: `LineReconciliationTab` — new component + +**Files:** +- Create: `src/components/ClaimDrawer/LineReconciliationTab.tsx` +- Create: `src/components/ClaimDrawer/LineReconciliationTab.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/components/ClaimDrawer/LineReconciliationTab.test.tsx`: + +```tsx +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render } from "@testing-library/react"; +import { LineReconciliationTab } from "./LineReconciliationTab"; +import type { LineReconciliationResponse } from "@/types"; + +afterEach(() => cleanup()); + +const matchedAll: LineReconciliationResponse = { + claimId: "CLM-1", + summary: { + billedTotal: "200.00", paidTotal: "160.00", + adjustmentTotal: "40.00", matchedLines: 2, totalLines: 2, + }, + lines: [ + { + claimServiceLine: { id: 1, lineNumber: 1, procedureQualifier: "HC", + procedureCode: "99213", modifiers: [], charge: "100.00", + units: "1", unitType: "UN", serviceDate: "2026-06-01" }, + serviceLinePayment: { id: 1, lineNumber: 1, procedureQualifier: "HC", + procedureCode: "99213", modifiers: [], charge: "100.00", + payment: "80.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" }, + status: "matched", + adjustments: [ + { groupCode: "CO", reasonCode: "45", amount: "20.00" }, + ], + }, + ], +}; + +describe("LineReconciliationTab", () => { + it("renders summary totals", () => { + const { container } = render(); + expect(container.textContent).toMatch(/billed/i); + expect(container.textContent).toMatch(/paid/i); + expect(container.textContent).toMatch(/adjustments/i); + }); + + it("renders full-match summary line", () => { + const { container } = render(); + expect(container.textContent).toMatch(/matched 2 of 2 lines/i); + }); + + it("renders partial-match summary line when lines are unmatched", () => { + const partial: LineReconciliationResponse = { + ...matchedAll, + summary: { ...matchedAll.summary, matchedLines: 1, totalLines: 2 }, + lines: [ + matchedAll.lines[0]!, + { + claimServiceLine: { id: 2, lineNumber: 2, procedureQualifier: "HC", + procedureCode: "99214", modifiers: [], charge: "100.00", + units: "1", unitType: "UN", serviceDate: "2026-06-01" }, + serviceLinePayment: null, + status: "unmatched_837_only", + adjustments: [], + }, + ], + }; + const { container } = render(); + expect(container.textContent).toMatch(/matched 1 of 2 lines/i); + }); + + it("renders unmatched 835 line with oxblood accent", () => { + const with835Only: LineReconciliationResponse = { + ...matchedAll, + lines: [ + matchedAll.lines[0]!, + { + claimServiceLine: null, + serviceLinePayment: { id: 2, lineNumber: 2, procedureQualifier: "HC", + procedureCode: "90837", modifiers: [], charge: "100.00", + payment: "70.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" }, + status: "unmatched_835_only", + adjustments: [], + }, + ], + }; + const { container } = render(); + expect(container.textContent).toMatch(/90837/); + expect(container.textContent).toMatch(/no 837 line matched/i); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx +``` + +Expected: ImportError. + +- [ ] **Step 3: Implement the component** + +Create `src/components/ClaimDrawer/LineReconciliationTab.tsx`: + +```tsx +import { fmt } from "@/lib/format"; +import type { + LineReconciliationResponse, + LineReconciliationRow, +} from "@/types"; + +/** + * Per-line reconciliation tab for the ClaimDrawer (SP7). + * + * Two-column layout: left = "Billed (837 SV1)", right = "Adjudicated (835 SVC)". + * Each row is a service line, matched when both sides are present; unmatched + * 835 lines appear at the bottom with an oxblood accent rail. Per-line CAS + * adjustments are listed under each row. + * + * Spec §6.2. + */ +export function LineReconciliationTab({ + data, +}: { + data: LineReconciliationResponse; +}) { + const { summary, lines } = data; + const allMatched = summary.matchedLines === summary.totalLines; + + // Separate matched+837_only (left column) from 835_only (right column tail) + const leftRows = lines.filter((l) => l.status !== "unmatched_835_only"); + const rightRows = lines; // all rows, in their original order + + return ( +
+
+
+ + + Billed + + {fmt(summary.billedTotal)} + + + + Paid + + {fmt(summary.paidTotal)} + + + + Adjustments + + {fmt(summary.adjustmentTotal)} + +
+ + {allMatched + ? `matched ${summary.matchedLines} of ${summary.totalLines} lines` + : `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`} + +
+ +
+
+

+ Billed (837 SV1) +

+ {leftRows.map((row) => ( + + ))} +
+
+

+ Adjudicated (835 SVC) +

+ {rightRows.map((row) => ( + + ))} +
+
+
+ ); +} + +function rowKey(row: LineReconciliationRow, side: "left" | "right"): string { + const ln = row.claimServiceLine?.lineNumber ?? row.serviceLinePayment?.lineNumber ?? -1; + return `${side}-${row.status}-${ln}`; +} + +function LineCard({ + row, + side, +}: { + row: LineReconciliationRow; + side: "left" | "right"; +}) { + const cl = row.claimServiceLine; + const svc = row.serviceLinePayment; + const isUnmatched835 = row.status === "unmatched_835_only"; + const accentColor = isUnmatched835 ? "var(--tt-oxblood)" : "var(--tt-ink-blue)"; + const procCode = cl?.procedureCode ?? svc?.procedureCode ?? "—"; + const lineNumber = cl?.lineNumber ?? svc?.lineNumber ?? null; + + return ( +
+
+
+ {procCode} + {lineNumber !== null && ( + #{lineNumber} + )} + {isUnmatched835 && ( + + no 837 line matched + + )} + {row.status === "unmatched_837_only" && ( + + no 835 line adjudicated this line + + )} +
+
+ {side === "left" && cl && {fmt(cl.charge)}} + {side === "right" && svc && {fmt(svc.payment)}} +
+
+ {row.adjustments.length > 0 && ( +
    + {row.adjustments.map((a, i) => ( +
  • + {a.groupCode} · {a.reasonCode} · {fmt(a.amount)} +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/ClaimDrawer/LineReconciliationTab.tsx src/components/ClaimDrawer/LineReconciliationTab.test.tsx +git commit -m "feat(sp7): LineReconciliationTab component" +``` + +--- + +## Task 19: Wire `LineReconciliationTab` into `ClaimDrawer` + +**Files:** +- Create: `src/hooks/useLineReconciliation.ts` +- Create: `src/hooks/useLineReconciliation.test.ts` +- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx` +- Modify: `src/components/ClaimDrawer/index.ts` + +- [ ] **Step 1: Write the failing hook test** + +Create `src/hooks/useLineReconciliation.test.ts`: + +```tsx +// @vitest-environment happy-dom +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { useLineReconciliation } from "./useLineReconciliation"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("useLineReconciliation", () => { + it("fetches the line reconciliation data on mount", async () => { + const fixture = { + claimId: "CLM-1", + summary: { billedTotal: "100", paidTotal: "80", + adjustmentTotal: "20", matchedLines: 1, totalLines: 1 }, + lines: [], + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => fixture, + })); + const { result } = renderHook(() => useLineReconciliation("CLM-1")); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.data).toEqual(fixture); + }); + + it("returns null when claimId is null", () => { + const { result } = renderHook(() => useLineReconciliation(null)); + expect(result.current.data).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it("exposes an error when fetch fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: false, status: 500, json: async () => ({}), + })); + const { result } = renderHook(() => useLineReconciliation("CLM-1")); + await waitFor(() => expect(result.current.error).not.toBeNull()); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +pnpm vitest run src/hooks/useLineReconciliation.test.ts +``` + +Expected: ImportError. + +- [ ] **Step 3: Implement the hook** + +Create `src/hooks/useLineReconciliation.ts`: + +```typescript +import { useEffect, useState } from "react"; +import type { LineReconciliationResponse } from "@/types"; + +/** + * Data hook for the ClaimDrawer's Line Reconciliation tab. + * + * Calls GET /api/claims/{claimId}/line-reconciliation on mount and when + * claimId changes. When claimId is null, returns a no-data state. + */ +export function useLineReconciliation(claimId: string | null) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(claimId !== null); + const [error, setError] = useState(null); + + useEffect(() => { + if (claimId === null) { + setData(null); + setLoading(false); + setError(null); + return; + } + let cancelled = false; + setLoading(true); + const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; + fetch(`${baseUrl}/api/claims/${claimId}/line-reconciliation`) + .then(async (r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return (await r.json()) as LineReconciliationResponse; + }) + .then((json) => { + if (cancelled) return; + setData(json); + setError(null); + }) + .catch((e) => { + if (cancelled) return; + setError(e as Error); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [claimId]); + + return { data, loading, error }; +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +pnpm vitest run src/hooks/useLineReconciliation.test.ts +``` + +Expected: 3 passed. + +- [ ] **Step 5: Wire the tab into `ClaimDrawer.tsx`** + +Read `src/components/ClaimDrawer/ClaimDrawer.tsx`. Find where the drawer renders its body sections (StateHistoryTimeline, RawSegmentsPanel, etc.). + +Add state for the active tab: + +```tsx +const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details"); +``` + +Add a tab strip: + +```tsx +
+ + +
+``` + +Conditionally render the tab body: + +```tsx +{activeTab === "line-reconciliation" ? ( + claimId ? ( + + ) : null +) : ( + // existing details body +)} +``` + +Where `LineReconciliationBody` is: + +```tsx +function LineReconciliationBody({ claimId }: { claimId: string }) { + const { data, loading, error } = useLineReconciliation(claimId); + if (loading) return
loading…
; + if (error) return
error: {error.message}
; + if (!data) return null; + return ; +} +``` + +Add the imports at the top: + +```tsx +import { useState } from "react"; +import { LineReconciliationTab } from "./LineReconciliationTab"; +import { useLineReconciliation } from "@/hooks/useLineReconciliation"; +``` + +- [ ] **Step 6: Update `ClaimDrawer.test.tsx` if needed** + +If the existing tests render the drawer body in a way that breaks (e.g. test snapshot expects "details" to be the default and only tab), update accordingly. Most tests should pass since "Details" is the default tab. + +- [ ] **Step 7: Run tests** + +```bash +pnpm vitest run src/components/ClaimDrawer/ src/hooks/useLineReconciliation.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Run typecheck** + +```bash +pnpm tsc --noEmit +``` + +Expected: 0 errors. + +- [ ] **Step 9: Commit** + +```bash +git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/ClaimDrawer.test.tsx src/components/ClaimDrawer/index.ts src/hooks/useLineReconciliation.ts src/hooks/useLineReconciliation.test.ts +git commit -m "feat(sp7): Line Reconciliation tab wired into ClaimDrawer" +``` + +--- + +## Phase 6 — Docs + smoke + +## Task 20: README "Per-Line Adjustment Audit" section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Find a good insertion point** + +```bash +grep -n "^## " README.md +``` + +Insert the new section after "## Inbox" (added in SP6) and before "## Persistence". + +- [ ] **Step 2: Add the section** + +```markdown +## Per-Line Adjustment Audit + +Every 835 CAS segment is tied back to the specific 837 service line it +adjudicates, instead of being aggregated to the claim level. The match +runs **eagerly** at 835 ingest time so the audit trail is stable across +re-reads (a re-migration is required if the algorithm changes). + +### Where to find it + +- **ClaimDrawer → "Line Reconciliation" tab** — side-by-side view of the + 837 SV1 lines and 835 SVC composites with per-line CAS reasons. +- **ClaimDrawer → ServiceLinesTable** — every billed line now shows its + paid amount and adjustment sum on the right. +- **RemitDrawer → CasAdjustmentsPanel** — each CAS row carries a + `proc · #N` reference; claim-level CAS clusters separately. +- **Inbox → MatchedRemitCard** — a `lines: N/M matched` badge lights up + amber when at least one line is unmatched. + +### Match criteria (strict) + +A 835 SVC composite matches a 837 SV1 line iff all four criteria align: + +| Field | Rule | +| ------------- | ----------------------------------------------- | +| Procedure | Exact match (case-insensitive, uppercased). | +| Modifiers | Set-equal (order-independent). | +| Service date | Exact match (both null counts as a match). | +| Units | Exact match (both null counts as a match). | + +Unmatched lines surface as a **soft warning** — the claim still posts to +PAID/PARTIAL/RECEIVED, but the Inbox shows the unmatched count and the +drawer surfaces a per-line "no 837 line matched" note. + +### Line-level endpoints + +| Method | Path | Notes | +| ------ | --------------------------------------------------- | ---------------------------------------------- | +| GET | `/api/claims/{claim_id}/line-reconciliation` | Dedicated view for the drawer tab. | +| GET | `/api/claims/{claim_id}` | Now includes a slim `lineReconciliation[]`. | +| GET | `/api/remittances/{remittance_id}` | Now includes `serviceLinePayments[]` + `claimLevelAdjustments[]`. | +| GET | `/api/inbox/lanes` | `matched_remittance` payload gains `matched_lines` + `total_lines`. | +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(sp7): README — Per-Line Adjustment Audit section" +``` + +--- + +## Task 21: Manual smoke test + +**Files:** none + +- [ ] **Step 1: Start backend** + +```bash +cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp7-smoke.db .venv/bin/python -m cyclone serve & +``` + +- [ ] **Step 2: Parse a sample 837 + 835 with overlapping service lines** + +```bash +curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837 +curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_835.txt" http://127.0.0.1:8000/api/parse-835 +``` + +(If these fixtures don't align on PCN / lines, build a small synthetic pair that does — copy `co_medicaid_837p.txt` and adjust the PCN + a single line's procedure_code to a value the 835 doesn't pay. Save the result in `backend/tests/fixtures/sp7_smoke/` if the existing fixtures don't fit.) + +- [ ] **Step 3: Hit the new endpoint** + +```bash +curl -s http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40 +# Find a paid claim's id, then: +curl -s http://127.0.0.1:8000/api/claims//line-reconciliation | python3 -m json.tool +``` + +Expected: a payload with `summary.matched_lines` + a `lines[]` array showing each 837 line paired with its 835 SVC composite (or null on the unmatched side), with per-line CAS adjustments. + +- [ ] **Step 4: Verify the soft-warning surface** + +In the response, check for: + +- A line with `status: "unmatched_837_only"` — confirms an 837 line the payer didn't pay. +- A line with `status: "unmatched_835_only"` — confirms an 835 line the 837 didn't bill (synthetic setup needed for this). + +- [ ] **Step 5: Hit the claim detail endpoint to confirm the slim projection** + +```bash +curl -s http://127.0.0.1:8000/api/claims/ | python3 -m json.tool | grep -A 5 lineReconciliation +``` + +Expected: a `lineReconciliation` array with one entry per billed line. + +- [ ] **Step 6: Kill servers** + +```bash +lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill +``` + +- [ ] **Step 7: Final full-suite run** + +```bash +cd backend && pytest -q +cd .. && pnpm vitest run --reporter=dot +``` + +Expected: backend green (same baseline as before SP7 — the 8 SP5 stream flakes + 3 TA1 flakes remain). Frontend green (328+ tests). + +- [ ] **Step 8: Final commit + push** + +```bash +git log --oneline main -25 +``` + +(Branch was kept on `main` per session convention; no push step.) + +--- + +## Self-review checklist + +- [x] **Spec coverage:** + - §2.1 Line Reconciliation tab → T18, T19 + - §2.2 Augmented ServiceLinesTable → T15 + - §2.3 Per-line attribution in RemitDrawer → T16 + - §2.4 Inbox badge → T17, T12 + - §3.1 `service_line_payments` table → T1, T2 + - §3.2 `line_reconciliations` table → T1, T3 + - §3.3 `CasAdjustment.service_line_payment_id` → T4 + - §3.4 No change to `service_lines` → implicit (no task needed) + - §4.1 `match_service_lines()` signature → T7 + - §4.2 Strict match criteria → T7 + - §4.3 Tie-breaker → T7 + - §4.4 Insertion order in `reconcile.run()` → T8 + - §5.1 New endpoint → T9 + - §5.2 Modified claim detail endpoint → T10 + - §5.3 Modified remit detail endpoint → T11 + - §5.4 Modified inbox lanes → T12 + - §6.1 ServiceLinesTable columns → T15 + - §6.2 LineReconciliationTab → T18, T19 + - §6.3 CasAdjustmentsPanel column → T16 + - §6.4 MatchedRemitCard badge → T17 + - §7.1 Unmatched 835 lines → T7 (test), T18 (rendered) + - §7.2 Unmatched 837 lines → T7 (test), T18 (rendered) + - §7.3 Reversals → T8 (`run` skips reversals for now; spec §7.3 is a follow-on, documented but not implemented in v1) + - §7.4 CLP-level CAS → T6, T11 + - §7.5 Multi-line remits → implicit (no schema constraint) + - §7.6 Remit-level CAS without service payment → T6 (no-op path) + - §7.7 Empty service_payments → T7 (test case) + - §7.8 Test isolation → all tests use the existing pattern; no SP5 fixture imports in new tests + - §9.1 Migration → T1 + - §9.2 ORM models → T2, T3, T4 + - §9.3 No backfill → implicit + - §9.4 Frontend types → T13 + - §9.5 Rollout → T21 + +- [x] **Placeholder scan:** no TBD / TODO / "implement later" / "similar to Task N" patterns. All test files contain actual test code. + +- [x] **Type consistency:** + - `match_service_lines` returns `list[LineMatch]` (T7) and T8 uses the same signature via the shims. + - `_persist_835_remit` signature matches across T6 and its tests. + - The endpoint `/api/claims/{claim_id}/line-reconciliation` is referenced consistently in T9 (backend), T19 (frontend hook), and the spec. + - `matchedLines` / `totalLines` appear in the API response (T12), the `InboxMatchedRemittance` type (T14), and the `MatchedRemitCard` component (T17). + +- [x] **Scope:** 21 tasks, one branch, one PR. Within a single plan. + +--- + +## Execution Handoff + +**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md`.** + +Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on the design phase — assumed to carry through to plan execution). + +Executing T1 → T21 in order, committing each, running the relevant subset of the suite per task.