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

87 KiB
Raw Blame History

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: SP1SP6 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
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

-- 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
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:

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
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:

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
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:

    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__:

        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:

    claim_level_adjustment_amount: Mapped[Decimal] = mapped_column(
        Numeric(12, 2), nullable=False, default=Decimal("0"), server_default=text("0")
    )
  • Step 3: Commit
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:

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
cd backend && pytest tests/test_acks.py tests/test_db_models.py -v

Expected: PASS.

  • Step 4: Commit
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:

"""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
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:

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:

                    for svc in cp.service_payments:
                        for adj in svc.adjustments:
                            s.add(_cas_adjustment_row(adj, remit_row.id))

Replace with:

                    _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
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
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
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:

"""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
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:

@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
cd backend && pytest tests/test_line_reconciliation.py -v

Expected: 11 passed.

  • Step 5: Commit
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:

"""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
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:

    # 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):

@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
cd backend && pytest tests/test_reconcile_line_level.py -v

Expected: PASS.

  • Step 5: Full backend suite
cd backend && pytest -q

Expected: same baseline.

  • Step 6: Commit
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:

"""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
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:

@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
cd backend && pytest tests/test_api_line_reconciliation.py -v

Expected: 2 passed.

  • Step 5: Commit
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:

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

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
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:

        # 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
cd backend && pytest -k "remit" -q

Expected: PASS.

  • Step 4: Commit
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:

                    # 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
cd backend && pytest tests/test_inbox_endpoints.py -v

Expected: PASS.

  • Step 4: Commit
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:

// ---------------------------------------------------------------------------
// 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:

  lineReconciliation: ClaimDetailLineReconciliation[];
  • Step 3: Update RemittanceDetail

Find the remittance detail type (probably named RemittanceDetail or similar — read the file to find it). Add:

  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
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
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:

  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
pnpm vitest run src/lib/inbox-api.test.ts

Expected: PASS.

  • Step 4: Commit
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 <th> and <td> 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:

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(
    <ServiceLinesTable
      serviceLines={[/* existing fixtures with line_number 1 and 2 */]}
      lineReconciliation={lineReconciliation}
    />
  );
  // 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 <td> cells)
});

it("renders em-dash for missing lineReconciliation entries", () => {
  const { container } = render(
    <ServiceLinesTable
      serviceLines={[/* one line fixture */]}
      lineReconciliation={[]}
    />
  );
  // 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
pnpm vitest run src/components/ClaimDrawer/ServiceLinesTable.test.tsx

Expected: PASS.

  • Step 6: Commit
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
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(<CasAdjustmentsPanel rows={rows} />);
  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(<CasAdjustmentsPanel rows={rows} />);
  expect(container.textContent).toMatch(/Claim-level adjustments/i);
});

(Adjust the prop names to match whatever the existing component uses.)

  • Step 5: Run tests
pnpm vitest run src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx

Expected: PASS.

  • Step 6: Commit
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:

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)";
// ...
<div className="text-xs font-mono" style={{ color: badgeColor }}>
  lines: {matched}/{total} matched
  {!allMatched && ` (${total - matched} unmatched)`}
</div>

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
it("renders full-match badge when matchedLines equals totalLines", () => {
  const rem = { /* existing fixture */, matchedLines: 4, totalLines: 4 };
  const { container } = render(<MatchedRemitCard matchedRemittance={rem} />);
  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(<MatchedRemitCard matchedRemittance={rem} />);
  expect(container.textContent).toMatch(/3\/4 matched/);
  expect(container.textContent).toMatch(/1 unmatched/);
});
  • Step 5: Run tests
pnpm vitest run src/components/ClaimDrawer/MatchedRemitCard.test.tsx

Expected: PASS.

  • Step 6: Commit
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:

// @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(<LineReconciliationTab data={matchedAll} />);
    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(<LineReconciliationTab data={matchedAll} />);
    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(<LineReconciliationTab data={partial} />);
    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(<LineReconciliationTab data={with835Only} />);
    expect(container.textContent).toMatch(/90837/);
    expect(container.textContent).toMatch(/no 837 line matched/i);
  });
});
  • Step 2: Run, confirm FAIL
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx

Expected: ImportError.

  • Step 3: Implement the component

Create src/components/ClaimDrawer/LineReconciliationTab.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 (
    <section className="flex flex-col gap-4 px-6 py-4">
      <header
        className="flex items-baseline justify-between"
        data-testid="line-reconciliation-summary"
      >
        <div className="flex gap-6 font-mono tabular-nums text-sm">
          <span data-testid="billed-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Billed
            </span>
            {fmt(summary.billedTotal)}
          </span>
          <span data-testid="paid-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Paid
            </span>
            {fmt(summary.paidTotal)}
          </span>
          <span data-testid="adjustments-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Adjustments
            </span>
            {fmt(summary.adjustmentTotal)}
          </span>
        </div>
        <span
          className="font-mono text-xs uppercase tracking-wider"
          style={{
            color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
          }}
          data-testid="match-summary"
        >
          {allMatched
            ? `matched ${summary.matchedLines} of ${summary.totalLines} lines`
            : `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`}
        </span>
      </header>

      <div className="grid grid-cols-2 gap-4">
        <div data-testid="left-column">
          <h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
            Billed (837 SV1)
          </h3>
          {leftRows.map((row) => (
            <LineCard key={rowKey(row, "left")} row={row} side="left" />
          ))}
        </div>
        <div data-testid="right-column">
          <h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
            Adjudicated (835 SVC)
          </h3>
          {rightRows.map((row) => (
            <LineCard key={rowKey(row, "right")} row={row} side="right" />
          ))}
        </div>
      </div>
    </section>
  );
}

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 (
    <div
      className="flex flex-col gap-1 py-2 px-3 mb-2"
      style={{
        borderLeft: `3px solid ${accentColor}`,
        background: "var(--tt-bg-elev, rgba(255,255,255,0.02))",
      }}
      data-line-status={row.status}
    >
      <div className="flex items-baseline justify-between">
        <div className="font-mono text-sm">
          <span className="font-semibold">{procCode}</span>
          {lineNumber !== null && (
            <span className="text-[color:var(--m-ink-tertiary)] ml-2">#{lineNumber}</span>
          )}
          {isUnmatched835 && (
            <span
              className="ml-2 text-xs italic"
              style={{ color: "var(--tt-oxblood)" }}
            >
              no 837 line matched
            </span>
          )}
          {row.status === "unmatched_837_only" && (
            <span
              className="ml-2 text-xs italic"
              style={{ color: "var(--tt-oxblood)" }}
            >
              no 835 line adjudicated this line
            </span>
          )}
        </div>
        <div className="font-mono tabular-nums text-xs">
          {side === "left" && cl && <span>{fmt(cl.charge)}</span>}
          {side === "right" && svc && <span>{fmt(svc.payment)}</span>}
        </div>
      </div>
      {row.adjustments.length > 0 && (
        <ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
          {row.adjustments.map((a, i) => (
            <li key={i}>
              {a.groupCode} · {a.reasonCode} · {fmt(a.amount)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • Step 4: Run, confirm PASS
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx

Expected: 4 passed.

  • Step 5: Commit
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:

// @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
pnpm vitest run src/hooks/useLineReconciliation.test.ts

Expected: ImportError.

  • Step 3: Implement the hook

Create src/hooks/useLineReconciliation.ts:

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<LineReconciliationResponse | null>(null);
  const [loading, setLoading] = useState<boolean>(claimId !== null);
  const [error, setError] = useState<Error | null>(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
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:

const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");

Add a tab strip:

<div className="flex gap-2 px-6 pt-4 border-b" style={{ borderColor: "var(--tt-bg)" }}>
  <button
    onClick={() => setActiveTab("details")}
    className="font-mono text-xs uppercase tracking-wider px-3 py-2"
    style={{
      color: activeTab === "details" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
      borderBottom: activeTab === "details" ? "2px solid var(--tt-amber)" : "2px solid transparent",
    }}
  >
    Details
  </button>
  <button
    onClick={() => setActiveTab("line-reconciliation")}
    className="font-mono text-xs uppercase tracking-wider px-3 py-2"
    style={{
      color: activeTab === "line-reconciliation" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
      borderBottom: activeTab === "line-reconciliation" ? "2px solid var(--tt-amber)" : "2px solid transparent",
    }}
  >
    Line Reconciliation
  </button>
</div>

Conditionally render the tab body:

{activeTab === "line-reconciliation" ? (
  claimId ? (
    <LineReconciliationBody claimId={claimId} />
  ) : null
) : (
  // existing details body
)}

Where LineReconciliationBody is:

function LineReconciliationBody({ claimId }: { claimId: string }) {
  const { data, loading, error } = useLineReconciliation(claimId);
  if (loading) return <div className="px-6 py-4 font-mono text-sm">loading</div>;
  if (error) return <div className="px-6 py-4 font-mono text-sm" style={{ color: "var(--tt-oxblood)" }}>error: {error.message}</div>;
  if (!data) return null;
  return <LineReconciliationTab data={data} />;
}

Add the imports at the top:

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
pnpm vitest run src/components/ClaimDrawer/ src/hooks/useLineReconciliation.test.ts

Expected: PASS.

  • Step 8: Run typecheck
pnpm tsc --noEmit

Expected: 0 errors.

  • Step 9: Commit
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

grep -n "^## " README.md

Insert the new section after "## Inbox" (added in SP6) and before "## Persistence".

  • Step 2: Add the section
## 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
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
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
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
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/<claim_id>/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

curl -s http://127.0.0.1:8000/api/claims/<claim_id> | python3 -m json.tool | grep -A 5 lineReconciliation

Expected: a lineReconciliation array with one entry per billed line.

  • Step 6: Kill servers
lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill
  • Step 7: Final full-suite run
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
git log --oneline main -25

(Branch was kept on main per session convention; no push step.)


Self-review checklist

  • 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
  • Placeholder scan: no TBD / TODO / "implement later" / "similar to Task N" patterns. All test files contain actual test code.

  • 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).
  • 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.