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 `