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