Files
cyclone/docs/superpowers/plans/2026-06-19-cyclone-db-reconciliation.md
T
Tyler 53010b89dd docs: add DB + reconciliation implementation plan (30 tasks, 7 phases)
Implements the spec at docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md.

Phase 1 — Backend foundation (T1-T3): SQLAlchemy dep, db.py skeleton,
PRAGMA user_version migration runner, 0001_initial.sql with 6 tables.
Phase 2 — ORM models (T4-T6): Batch, Claim, Remittance, CasAdjustment,
Match, ActivityEvent + ClaimState enum.
Phase 3 — Reconciliation pure functions (T7-T8): match() with date
window + multi-claim fallback, apply_payment/apply_reversal,
split_unmatched.
Phase 4 — Store facade refactor (T9-T12): CycloneStore replaces
InMemoryStore with same public API + list_unmatched / manual_match /
manual_unmatch; reconcile.run orchestrator wired into store.add().
Phase 5 — API additions (T13-T17): db.init() in startup,
GET /api/reconciliation/unmatched, POST /match, POST /unmatch,
parse-835 response includes reconciliation summary; TODO marker
removal verification.
Phase 6 — Frontend (T18-T26): ClaimState types, api.listUnmatched/
matchRemit/unmatchClaim, useReconciliation hook, ClaimStateBadge
primitive (7 states, same palette), Reconciliation page, sidebar
nav entry, vitest worktree exclude.
Phase 7 — Docs + smoke (T27-T30): README persistence section + DB
URL docs, tick off sub-project 1 plan boxes, full end-to-end smoke
procedure, merge to main.

Total: 30 tasks across 7 phases. TDD discipline per task with
~32 new backend tests + ~4 new frontend tests. Target after
completion: 210 backend + 7 frontend tests passing.
2026-06-19 20:51:31 -06:00

149 KiB

Cyclone DB + Reconciliation 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.

Goal: Replace the in-memory InMemoryStore with SQLite persistence via SQLAlchemy 2.0; add automatic 837P ↔ 835 claim reconciliation on every successful 835 parse; ship a manual /reconciliation page for unmatched claims and remittances; extend the claim lifecycle to a 7-state model with reversal handling. Persist data across backend restarts and remove the adjustmentAmount: 0.0 TODO marker from store.py:376.

Architecture: Three new modules — cyclone.db (SQLAlchemy 2.0 engine, SessionLocal, declarative Base, 6 ORM models), cyclone.db_migrate (~40 LOC PRAGMA user_version runner), cyclone.reconcile (pure functions: match, apply_payment, apply_reversal, split_unmatched). The existing cyclone.store becomes a thin facade over SQLAlchemy, preserving its public API so api.py and the mappers change minimally. Reconciliation is triggered inside store.add() after the ERA is persisted; failures are fail-soft. One new page (/reconciliation), one new hook (useReconciliation), one new primitive (claim-state-badge). SQLite at ~/.local/share/cyclone/cyclone.db, overridable via CYCLONE_DB_URL.

Tech Stack: Python 3.11+, FastAPI (existing), SQLAlchemy 2.0 (NEW), pytest (existing), Pydantic v2 (existing). React 18 + Vite + TypeScript + Tailwind + @tanstack/react-query v5 (existing), lucide-react, sonner, zustand. No new npm deps.

Spec: docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md (approved at commit cf9af8e)

Worktree: Use superpowers:using-git-worktrees to create .worktrees/db-reconciliation from main before Task 1.


File map

New backend files

File Responsibility
backend/src/cyclone/db.py init_db(), SessionLocal(), Base, 6 ORM model classes, ClaimState enum
backend/src/cyclone/db_migrate.py run(engine)PRAGMA user_version runner (~40 LOC)
backend/src/cyclone/reconcile.py Pure functions: match, apply_payment, apply_reversal, split_unmatched, run (orchestrator)
backend/src/cyclone/migrations/0001_initial.sql DDL for all 6 tables
backend/tests/test_reconcile.py ~12 pure-function tests
backend/tests/test_db_migrate.py ~3 migration tests
backend/tests/test_store_reconcile.py ~10 integration tests (in-memory SQLite)

Modified backend files

File Change
backend/pyproject.toml Add sqlalchemy>=2.0,<3
backend/src/cyclone/store.py Rewrite internals from list[BatchRecord] to SQLAlchemy; preserve public API; add list_unmatched, manual_match, manual_unmatch
backend/src/cyclone/api.py Add GET /api/reconciliation/unmatched, POST /api/reconciliation/match, POST /api/reconciliation/unmatch; update parse-835 response
backend/src/cyclone/__main__.py Call db.init() before uvicorn starts
backend/tests/test_api_gets.py Extend with 3 new endpoint tests
backend/tests/test_api_parse_persists.py Extend with reconcile-trigger test + fault-injection test

New frontend files

File Responsibility
src/hooks/useReconciliation.ts useReconciliation() — query + match + unmatch mutations
src/hooks/useReconciliation.test.ts ~2 tests
src/components/ui/claim-state-badge.tsx 7-state badge reusing existing palette
src/pages/Reconciliation.tsx Two-column unmatched view + Match button
src/pages/Reconciliation.test.tsx ~2 tests

Modified frontend files

File Change
src/types/index.ts Add ClaimState enum (7 values), Match, UnmatchedClaim, UnmatchedRemittance
src/lib/api.ts Add listUnmatched, matchRemit, unmatchClaim
src/components/StatusBadge.tsx Delegate 7-state subset to new primitive
src/components/Sidebar.tsx Add Reconciliation nav item with badge count
src/pages/Claims.tsx Render new 7-state badge; accept claim.state directly
src/pages/Remittances.tsx Render adjustmentAmount from API
vitest.config.ts Exclude .worktrees/ from test discovery

Modified docs files

File Change
README.md Document CYCLONE_DB_URL, default DB path, backup recipe; update roadmap
docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md Tick off the 107 unchecked boxes (housekeeping)

Phase 1 — Backend foundation

Task 1: Add SQLAlchemy dependency

Files:

  • Modify: backend/pyproject.toml

  • Step 1: Add sqlalchemy to dependencies

In backend/pyproject.toml, in the dependencies list (under [project]), add:

    "sqlalchemy>=2.0,<3",

The block should look like:

dependencies = [
    "fastapi>=0.110",
    "uvicorn[standard]>=0.27",
    "pydantic>=2.6,<3",
    "sqlalchemy>=2.0,<3",
]
  • Step 2: Install the new dep into the venv

Run: cd backend && .venv/bin/pip install -e '.[dev]' Expected: installs sqlalchemy without error.

  • Step 3: Verify import works

Run: cd backend && .venv/bin/python -c "import sqlalchemy; print(sqlalchemy.__version__)" Expected: prints a version like 2.0.x.

  • Step 4: Commit
git add backend/pyproject.toml backend/uv.lock 2>/dev/null || git add backend/pyproject.toml
cd backend && .venv/bin/pip freeze | grep -i sqlalchemy > /tmp/sqla.txt && git add /tmp/sqla.txt 2>/dev/null || true
git commit -m "build(backend): add sqlalchemy>=2.0,<3 dependency"

(If backend/uv.lock doesn't exist, omit it from git add.)


Task 2: cyclone.db skeleton — Base, SessionLocal, engine factory, init_db

Files:

  • Create: backend/src/cyclone/db.py

  • Create: backend/tests/test_db.py

  • Step 1: Write the failing tests

backend/tests/test_db.py:

"""Tests for the SQLAlchemy engine + session factory + init_db."""

from __future__ import annotations

import os
from pathlib import Path

import pytest
import sqlalchemy as sa

from cyclone import db


@pytest.fixture(autouse=True)
def _isolated_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """Point CYCLONE_DB_URL at a temp file for every test in this module."""
    url = f"sqlite:///{tmp_path}/test.db"
    monkeypatch.setenv("CYCLONE_DB_URL", url)
    # Reset module-level state so each test gets a fresh engine.
    db._reset_for_tests()


def test_init_db_creates_engine() -> None:
    db.init_db()
    assert db.engine is not None
    assert isinstance(db.engine, sa.Engine)


def test_init_db_creates_all_tables(tmp_path: Path) -> None:
    db.init_db()
    # Probe a known future table name from the schema. We use
    # `inspect` rather than importing the model class to keep this
    # test independent of model implementation.
    inspector = sa.inspect(db.engine)
    # In Task 3 we add a Claim table; until then this is empty.
    assert isinstance(inspector.get_table_names(), list)


def test_session_local_returns_session() -> None:
    db.init_db()
    with db.SessionLocal() as s:
        assert s.is_active
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_db.py -v Expected: ModuleNotFoundError: No module named 'cyclone.db' or AttributeError on db.engine.

  • Step 3: Implement cyclone.db skeleton

backend/src/cyclone/db.py:

"""SQLAlchemy 2.0 engine, session factory, and declarative Base.

`init_db()` is called from `cyclone.__main__:serve` before uvicorn starts.
It reads `CYCLONE_DB_URL` (default `sqlite:///~/.local/share/cyclone/cyclone.db`),
creates the engine, runs `Base.metadata.create_all`, then runs the migration
runner. Calling `init_db()` is idempotent — calling it twice on the same URL
re-uses the same engine.
"""

from __future__ import annotations

import os
from pathlib import Path

import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase, sessionmaker


DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
DEFAULT_DB_URL = f"sqlite:///{DEFAULT_DB_PATH}"


class Base(DeclarativeBase):
    """Declarative base for all ORM models in the project."""


_engine: sa.Engine | None = None
_SessionLocal: sessionmaker[sa.orm.Session] | None = None


def _resolve_url() -> str:
    url = os.environ.get("CYCLONE_DB_URL")
    if url:
        return url
    DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    return DEFAULT_DB_URL


def _make_engine(url: str) -> sa.Engine:
    """Build an Engine with sensible defaults for SQLite + FastAPI."""
    connect_args: dict[str, object] = {}
    if url.startswith("sqlite"):
        connect_args = {"check_same_thread": False}
    return sa.create_engine(
        url,
        connect_args=connect_args,
        future=True,
    )


def init_db() -> None:
    """Create the engine + session factory; run create_all + migrations.

    Idempotent. Safe to call multiple times for the same URL.
    """
    global _engine, _SessionLocal
    if _engine is not None:
        return  # already initialized

    url = _resolve_url()
    _engine = _make_engine(url)

    # Import here to avoid circular imports with models.
    from cyclone import db_migrate  # noqa: WPS433 (deliberate late import)

    Base.metadata.create_all(_engine)
    db_migrate.run(_engine)

    _SessionLocal = sessionmaker(
        bind=_engine,
        autoflush=False,
        autocommit=False,
        expire_on_commit=False,
    )


def _reset_for_tests() -> None:
    """Clear module-level state. Test-only."""
    global _engine, _SessionLocal
    if _engine is not None:
        _engine.dispose()
    _engine = None
    _SessionLocal = None


@property
def engine() -> sa.Engine:
    if _engine is None:
        raise RuntimeError("db.init_db() has not been called")
    return _engine


@property
def SessionLocal() -> sessionmaker[sa.orm.Session]:
    if _SessionLocal is None:
        raise RuntimeError("db.init_db() has not been called")
    return _SessionLocal


# Make these accessible as module attributes (`db.engine`, `db.SessionLocal`)
# rather than only as properties, so `db.engine` works in tests and code.
engine = property(lambda self: engine.fget(None))  # type: ignore[assignment]
SessionLocal = property(lambda self: SessionLocal.fget(None))  # type: ignore[assignment]

Note: the two property re-exports at the bottom are a deliberate trick so that db.engine and db.SessionLocal work both as module attributes and inside the test fixture that needs to reset them. If your linter complains, replace with explicit module-level getters:

def engine() -> sa.Engine: ...
def SessionLocal() -> sessionmaker: ...

and call db.engine() instead of db.engine everywhere. Pick one style and use it consistently.

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_db.py -v Expected: 3 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db.py
git commit -m "feat(backend): add cyclone.db with engine, SessionLocal, Base, init_db"

Task 3: cyclone.db_migrate runner + initial migration

Files:

  • Create: backend/src/cyclone/db_migrate.py

  • Create: backend/src/cyclone/migrations/0001_initial.sql

  • Create: backend/tests/test_db_migrate.py

  • Step 1: Write the failing tests

backend/tests/test_db_migrate.py:

"""Tests for the PRAGMA user_version migration runner."""

from __future__ import annotations

from pathlib import Path

import pytest
import sqlalchemy as sa

from cyclone import db_migrate


def _fresh_engine(tmp_path: Path) -> sa.Engine:
    return sa.create_engine(f"sqlite:///{tmp_path}/m.db", future=True)


def _user_version(engine: sa.Engine) -> int:
    with engine.connect() as c:
        return c.exec_driver_sql("PRAGMA user_version").scalar() or 0


def test_run_on_empty_db_bumps_to_latest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
    (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n")

    engine = _fresh_engine(tmp_path)
    db_migrate.run(engine)

    assert _user_version(engine) == 1
    with engine.connect() as c:
        rows = c.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table' AND name='t'").all()
    assert len(rows) == 1


def test_run_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
    (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n")

    engine = _fresh_engine(tmp_path)
    db_migrate.run(engine)
    db_migrate.run(engine)  # second call should be a no-op

    assert _user_version(engine) == 1


def test_run_skips_already_applied_migrations(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
    (tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t1 (id INTEGER);\n")
    (tmp_path / "0002_add_col.sql").write_text("-- version: 2\nALTER TABLE t1 ADD COLUMN name TEXT;\n")

    engine = _fresh_engine(tmp_path)
    db_migrate.run(engine)
    assert _user_version(engine) == 2

    # Modify 0002 (a no-op for SQLite ALTER, but we want to verify the
    # runner doesn't re-run already-applied migrations).
    db_migrate.run(engine)
    assert _user_version(engine) == 2


def test_run_raises_on_missing_version_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
    (tmp_path / "0001_bad.sql").write_text("CREATE TABLE t (id INTEGER);\n")  # no header

    engine = _fresh_engine(tmp_path)
    with pytest.raises(RuntimeError, match="missing '-- version"):
        db_migrate.run(engine)
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_db_migrate.py -v Expected: ModuleNotFoundError: No module named 'cyclone.db_migrate'.

  • Step 3: Implement cyclone.db_migrate

backend/src/cyclone/db_migrate.py:

"""PRAGMA user_version-based migration runner.

Each migration is a `.sql` file under `backend/src/cyclone/migrations/`
with a leading comment of the form `-- version: N`. The runner reads
the current `PRAGMA user_version`, sorts migrations by filename, and
applies any whose version is greater than the current value. Each
migration runs in its own transaction; a failure rolls back the
version bump so the next startup retries.

This is intentionally NOT Alembic. One operator, one machine, one
schema-version counter.
"""

from __future__ import annotations

import re
from pathlib import Path

import sqlalchemy as sa

MIGRATIONS_DIR = Path(__file__).parent / "migrations"

_VERSION_RE = re.compile(r"^--\s*version:\s*(\d+)", re.MULTILINE)


def _migration_version(sql: str, filename: str) -> int:
    m = _VERSION_RE.search(sql)
    if not m:
        raise RuntimeError(
            f"{filename}: missing '-- version: N' header comment"
        )
    return int(m.group(1))


def _strip_comments(sql: str) -> str:
    """Remove `--` line comments; preserve everything else."""
    lines = []
    for line in sql.splitlines():
        stripped = line.strip()
        if stripped.startswith("--"):
            continue
        lines.append(line)
    return "\n".join(lines)


def run(engine: sa.Engine) -> None:
    """Apply any pending migrations. Idempotent."""
    migrations_dir = MIGRATIONS_DIR
    if not migrations_dir.exists():
        return  # no migrations directory yet — fresh checkout, no-op

    migration_files = sorted(migrations_dir.glob("*.sql"))
    if not migration_files:
        return

    with engine.begin() as conn:
        current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0

    for path in migration_files:
        sql = path.read_text()
        version = _migration_version(sql, path.name)
        if version <= current:
            continue

        statements_sql = _strip_comments(sql)
        statements = [s.strip() for s in statements_sql.split(";") if s.strip()]

        with engine.begin() as conn:
            for stmt in statements:
                conn.exec_driver_sql(stmt)
            conn.exec_driver_sql(f"PRAGMA user_version = {version}")

        current = version
  • Step 4: Create the initial migration

Create the directory backend/src/cyclone/migrations/ (it may already exist).

backend/src/cyclone/migrations/0001_initial.sql:

-- version: 1
-- Initial schema: 6 tables for Cyclone DB + reconciliation.

CREATE TABLE batches (
    id TEXT PRIMARY KEY,
    kind TEXT NOT NULL,
    input_filename TEXT NOT NULL,
    parsed_at DATETIME NOT NULL,
    totals_json TEXT,
    validation_json TEXT,
    raw_result_json TEXT
);
CREATE INDEX ix_batches_parsed_at ON batches(parsed_at DESC);

CREATE TABLE claims (
    id TEXT PRIMARY KEY,
    batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
    patient_control_number TEXT NOT NULL,
    service_date_from DATE,
    service_date_to DATE,
    charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
    provider_npi TEXT,
    payer_id TEXT,
    state TEXT NOT NULL DEFAULT 'submitted',
    state_before_reversal TEXT,
    matched_remittance_id TEXT REFERENCES remittances(id),
    raw_json TEXT,
    UNIQUE (batch_id, patient_control_number)
);
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);

CREATE TABLE remittances (
    id TEXT PRIMARY KEY,
    batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
    payer_claim_control_number TEXT NOT NULL,
    claim_id TEXT REFERENCES claims(id),
    status_code TEXT NOT NULL,
    status_label TEXT,
    total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,
    total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,
    patient_responsibility NUMERIC(12, 2),
    adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
    received_at DATETIME NOT NULL,
    service_date DATE,
    is_reversal INTEGER NOT NULL DEFAULT 0,
    raw_json TEXT,
    UNIQUE (batch_id, payer_claim_control_number)
);
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
CREATE INDEX ix_remittances_status_code ON remittances(status_code);

CREATE TABLE cas_adjustments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
    group_code TEXT NOT NULL,
    reason_code TEXT NOT NULL,
    amount NUMERIC(12, 2) NOT NULL,
    quantity NUMERIC(10, 2)
);
CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);

CREATE TABLE matches (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    claim_id TEXT NOT NULL UNIQUE REFERENCES claims(id) ON DELETE CASCADE,
    remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
    strategy TEXT NOT NULL,
    matched_at DATETIME NOT NULL,
    prior_claim_state TEXT,
    is_reversal INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX ix_matches_remittance_id ON matches(remittance_id);
CREATE INDEX ix_matches_matched_at ON matches(matched_at);

CREATE TABLE activity_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ts DATETIME NOT NULL,
    kind TEXT NOT NULL,
    batch_id TEXT,
    claim_id TEXT,
    remittance_id TEXT,
    payload_json TEXT
);
CREATE INDEX ix_activity_events_ts ON activity_events(ts DESC);
CREATE INDEX ix_activity_events_kind ON activity_events(kind);
  • Step 5: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_db_migrate.py -v Expected: 4 tests pass.

  • Step 6: Commit
git add backend/src/cyclone/db_migrate.py backend/src/cyclone/migrations/ backend/tests/test_db_migrate.py
git commit -m "feat(backend): add PRAGMA user_version migration runner + 0001_initial"

Phase 2 — ORM models

Task 4: Batch + ClaimState enum + Claim ORM models

Files:

  • Modify: backend/src/cyclone/db.py

  • Create: backend/tests/test_db_models.py

  • Step 1: Write the failing tests

backend/tests/test_db_models.py:

"""Tests for ORM model definitions and CRUD."""

from __future__ import annotations

import enum
from datetime import date, datetime, timezone
from decimal import Decimal

import pytest

from cyclone import db
from cyclone.db import Base, Batch, Claim, ClaimState


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()


def test_claim_state_enum_values():
    assert ClaimState.SUBMITTED.value == "submitted"
    assert ClaimState.RECEIVED.value == "received"
    assert ClaimState.PAID.value == "paid"
    assert ClaimState.PARTIAL.value == "partial"
    assert ClaimState.DENIED.value == "denied"
    assert ClaimState.RECONCILED.value == "reconciled"
    assert ClaimState.REVERSED.value == "reversed"
    # 7 values total.
    assert len(list(ClaimState)) == 7


def test_create_and_query_batch():
    with db.SessionLocal() as s:
        b = Batch(
            id="b1",
            kind="837p",
            input_filename="test.txt",
            parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
            totals_json={"total_claims": 1},
            validation_json={"passed": True, "warnings": [], "errors": []},
            raw_result_json={"_": "stub"},
        )
        s.add(b)
        s.commit()

    with db.SessionLocal() as s:
        loaded = s.get(Batch, "b1")
        assert loaded is not None
        assert loaded.kind == "837p"
        assert loaded.totals_json == {"total_claims": 1}


def test_create_and_query_claim_with_default_state():
    with db.SessionLocal() as s:
        s.add(Batch(
            id="b1", kind="837p", input_filename="x",
            parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        c = Claim(
            id="CLM-1",
            batch_id="b1",
            patient_control_number="CLM-1",
            service_date_from=date(2026, 6, 1),
            service_date_to=date(2026, 6, 1),
            charge_amount=Decimal("124.00"),
            provider_npi="1234567890",
            payer_id="SKCO0",
            state=ClaimState.SUBMITTED,
        )
        s.add(c)
        s.commit()

    with db.SessionLocal() as s:
        loaded = s.get(Claim, "CLM-1")
        assert loaded is not None
        assert loaded.state == ClaimState.SUBMITTED
        assert loaded.charge_amount == Decimal("124.00")
        assert loaded.service_date_from == date(2026, 6, 1)
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v Expected: ImportError on cyclone.db.Claim / cyclone.db.ClaimState.

  • Step 3: Add ClaimState enum + Batch + Claim models

Append to backend/src/cyclone/db.py:

import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Optional

from sqlalchemy import (
    Boolean,
    Date,
    DateTime,
    Enum,
    ForeignKey,
    Index,
    Integer,
    Numeric,
    String,
    Text,
    UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship


class ClaimState(str, enum.Enum):
    SUBMITTED = "submitted"
    RECEIVED = "received"
    PAID = "paid"
    PARTIAL = "partial"
    DENIED = "denied"
    RECONCILED = "reconciled"
    REVERSED = "reversed"


class Batch(Base):
    __tablename__ = "batches"

    id: Mapped[str] = mapped_column(String(32), primary_key=True)
    kind: Mapped[str] = mapped_column(String(8), nullable=False)
    input_filename: Mapped[str] = mapped_column(String(512), nullable=False)
    parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    totals_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
    validation_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
    raw_result_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)

    claims: Mapped[list["Claim"]] = relationship(
        back_populates="batch", cascade="all, delete-orphan"
    )
    remittances: Mapped[list["Remittance"]] = relationship(
        back_populates="batch", cascade="all, delete-orphan"
    )

    __table_args__ = (
        Index("ix_batches_parsed_at", "parsed_at"),
    )


class Claim(Base):
    __tablename__ = "claims"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
    )
    patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
    service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
    payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    state: Mapped[ClaimState] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
    )
    state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=True
    )
    matched_remittance_id: Mapped[Optional[str]] = mapped_column(
        String(64), ForeignKey("remittances.id"), nullable=True
    )
    raw_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)

    batch: Mapped["Batch"] = relationship(back_populates="claims")

    __table_args__ = (
        UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"),
        Index("ix_claims_state", "state"),
        Index("ix_claims_patient_control_number", "patient_control_number"),
        Index("ix_claims_service_date_from", "service_date_from"),
    )

Important: Claim.matched_remittance_id references remittances.id (FK to Remittance). But Remittance is defined in Task 5. SQLAlchemy 2.0 resolves forward references via the string "remittances.id" in the ForeignKey call, so this works even though Remittance doesn't exist yet. The relationship on Batch already references "Claim" and "Remittance" as strings — same mechanism.

Migration update: The migration 0001_initial.sql declared claims.matched_remittance_id REFERENCES remittances(id). SQLAlchemy's create_all (which init_db calls) will create the same constraint. But the migration runner is the source of truth for fresh DBs. The create_all call is a safety net for tests that want to skip migrations. Both produce identical DDL.

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v Expected: 3 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Batch + Claim ORM models + ClaimState enum"

Task 5: Remittance + CasAdjustment ORM models

Files:

  • Modify: backend/src/cyclone/db.py

  • Modify: backend/tests/test_db_models.py

  • Step 1: Add failing tests

Append to backend/tests/test_db_models.py:

from cyclone.db import CasAdjustment, Remittance


def test_create_and_query_remittance():
    with db.SessionLocal() as s:
        s.add(Batch(
            id="b1", kind="835", input_filename="x",
            parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        r = Remittance(
            id="CLP-1",
            batch_id="b1",
            payer_claim_control_number="CLP-1",
            status_code="1",
            total_charge=Decimal("124.00"),
            total_paid=Decimal("124.00"),
            received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
            service_date=date(2026, 6, 1),
            is_reversal=False,
        )
        s.add(r)
        s.commit()

    with db.SessionLocal() as s:
        loaded = s.get(Remittance, "CLP-1")
        assert loaded is not None
        assert loaded.status_code == "1"
        assert loaded.total_paid == Decimal("124.00")
        assert loaded.is_reversal is False
        assert loaded.adjustment_amount == Decimal("0")  # default


def test_cas_adjustment_aggregation(tmp_path, monkeypatch):
    """Sum of CAS rows for a Remittance equals expected total."""
    with db.SessionLocal() as s:
        s.add(Batch(
            id="b1", kind="835", input_filename="x",
            parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        r = Remittance(
            id="CLP-1", batch_id="b1",
            payer_claim_control_number="CLP-1", status_code="1",
            total_charge=Decimal("124.00"), total_paid=Decimal("62.00"),
            received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        )
        s.add(r)
        s.flush()
        s.add_all([
            CasAdjustment(remittance_id="CLP-1", group_code="CO", reason_code="45",
                          amount=Decimal("30.00")),
            CasAdjustment(remittance_id="CLP-1", group_code="PR", reason_code="1",
                          amount=Decimal("32.00")),
        ])
        s.commit()

    from sqlalchemy import select, func
    with db.SessionLocal() as s:
        total = s.execute(
            select(func.sum(CasAdjustment.amount))
            .where(CasAdjustment.remittance_id == "CLP-1")
        ).scalar_one()
        assert total == Decimal("62.00")
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v -k "remittance or cas_adjustment" Expected: ImportError on cyclone.db.Remittance.

  • Step 3: Add Remittance + CasAdjustment models

Append to backend/src/cyclone/db.py:

class Remittance(Base):
    __tablename__ = "remittances"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    batch_id: Mapped[str] = mapped_column(
        String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
    )
    payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
    claim_id: Mapped[Optional[str]] = mapped_column(
        String(64), ForeignKey("claims.id"), nullable=True
    )
    status_code: Mapped[str] = mapped_column(String(4), nullable=False)
    status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    total_paid: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    patient_responsibility: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 2), nullable=True)
    adjustment_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
    received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
    raw_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)

    batch: Mapped["Batch"] = relationship(back_populates="remittances")
    cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
        back_populates="remittance", cascade="all, delete-orphan"
    )

    __table_args__ = (
        UniqueConstraint("batch_id", "payer_claim_control_number", name="uq_remits_batch_pcn"),
        Index("ix_remittances_claim_id", "claim_id"),
        Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
        Index("ix_remittances_status_code", "status_code"),
    )


class CasAdjustment(Base):
    __tablename__ = "cas_adjustments"

    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
    )
    group_code: Mapped[str] = mapped_column(String(4), nullable=False)
    reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
    amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)

    remittance: Mapped["Remittance"] = relationship(back_populates="cas_adjustments")

    __table_args__ = (
        Index("ix_cas_adjustments_remittance_id", "remittance_id"),
    )
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v Expected: 5 tests pass (3 from Task 4 + 2 from this task).

  • Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Remittance + CasAdjustment ORM models"

Task 6: Match + ActivityEvent ORM models

Files:

  • Modify: backend/src/cyclone/db.py

  • Modify: backend/tests/test_db_models.py

  • Step 1: Add failing tests

Append to backend/tests/test_db_models.py:

from cyclone.db import ActivityEvent, Match


def test_create_match_and_activity_event():
    with db.SessionLocal() as s:
        s.add(Batch(
            id="b1", kind="835", input_filename="x",
            parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        s.add(Claim(
            id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
            charge_amount=Decimal("124.00"), state=ClaimState.SUBMITTED,
        ))
        s.add(Remittance(
            id="CLP-1", batch_id="b1", payer_claim_control_number="CLM-1",
            status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
            received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        m = Match(
            claim_id="CLM-1", remittance_id="CLP-1",
            strategy="auto", is_reversal=False,
            matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
        )
        s.add(m)
        s.flush()
        s.add(ActivityEvent(
            ts=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
            kind="reconcile", batch_id="b1", claim_id="CLM-1",
            payload_json={"matched": 1},
        ))
        s.commit()

    with db.SessionLocal() as s:
        loaded = s.get(Match, 1)  # autoincrement id
        assert loaded is not None
        assert loaded.strategy == "auto"
        assert loaded.is_reversal is False

        events = s.query(ActivityEvent).all()
        assert len(events) == 1
        assert events[0].kind == "reconcile"
        assert events[0].payload_json == {"matched": 1}


def test_match_unique_per_claim():
    """A Claim can only have one current Match row."""
    import sqlalchemy.exc
    with db.SessionLocal() as s:
        s.add(Batch(
            id="b1", kind="835", input_filename="x",
            parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        ))
        s.add(Claim(id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
                    charge_amount=Decimal("0"), state=ClaimState.SUBMITTED))
        s.add(Remittance(id="CLP-1", batch_id="b1", payer_claim_control_number="x",
                         status_code="1", received_at=datetime.now(timezone.utc)))
        s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
                         status_code="1", received_at=datetime.now(timezone.utc)))
        s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
                    matched_at=datetime.now(timezone.utc)))
        s.commit()

        s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
                    matched_at=datetime.now(timezone.utc)))
        with pytest.raises(sqlalchemy.exc.IntegrityError):
            s.commit()
        s.rollback()
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v -k "match or activity" Expected: ImportError on cyclone.db.Match.

  • Step 3: Add Match + ActivityEvent models

Append to backend/src/cyclone/db.py:

class Match(Base):
    __tablename__ = "matches"

    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, unique=True,
    )
    remittance_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
    )
    strategy: Mapped[str] = mapped_column(String(16), nullable=False)
    matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
        Enum(ClaimState, native_enum=False), nullable=True
    )
    is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

    __table_args__ = (
        Index("ix_matches_remittance_id", "remittance_id"),
        Index("ix_matches_matched_at", "matched_at"),
    )


class ActivityEvent(Base):
    __tablename__ = "activity_events"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    kind: Mapped[str] = mapped_column(String(32), nullable=False)
    batch_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
    claim_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    remittance_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    payload_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)

    __table_args__ = (
        Index("ix_activity_events_ts", "ts"),
        Index("ix_activity_events_kind", "kind"),
    )
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v Expected: 7 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Match + ActivityEvent ORM models"

Phase 3 — Reconciliation pure functions

Task 7: ApplyIntent + ReconcileResult types and reconcile.match

Files:

  • Create: backend/src/cyclone/reconcile.py

  • Create: backend/tests/test_reconcile.py

  • Step 1: Write the failing tests

backend/tests/test_reconcile.py:

"""Tests for the pure reconciliation functions.

These tests use lightweight stand-in objects (namedtuples) rather than
SQLAlchemy ORM instances, so the reconcile module has zero DB dependency.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional

import pytest

from cyclone import reconcile


# --- Stand-in types -----------------------------------------------------------


@dataclass
class FakeClaim:
    id: str
    patient_control_number: str
    service_date_from: Optional[date]
    state: str = "submitted"
    matched_remittance_id: Optional[str] = None


@dataclass
class FakeRemit:
    id: str
    payer_claim_control_number: str
    service_date: Optional[date]
    is_reversal: bool = False


@dataclass
class FakeMatch:
    claim: FakeClaim
    remittance: FakeRemit
    strategy: str
    is_reversal: bool


# --- match() ------------------------------------------------------------------


def test_match_same_pcn_within_window_matches():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
    matches = reconcile.match(claims, remits, window_days=7)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-1"
    assert matches[0].remittance.id == "CLP-1"
    assert matches[0].strategy == "auto"


def test_match_dates_outside_window_unmatched():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 7, 1))]  # 30d apart
    matches = reconcile.match(claims, remits, window_days=7)
    assert matches == []


def test_match_different_pcn_unmatched():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-B", date(2026, 6, 1))]
    matches = reconcile.match(claims, remits)
    assert matches == []


def test_match_multiple_claims_same_pcn_picks_closest_date():
    claims = [
        FakeClaim("CLM-1", "PCN-A", date(2026, 5, 1)),
        FakeClaim("CLM-2", "PCN-A", date(2026, 6, 1)),  # closer
    ]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-2"


def test_match_multiple_remits_same_pcn_each_gets_own_claim():
    claims = [
        FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1)),
        FakeClaim("CLM-2", "PCN-A", date(2026, 6, 15)),
    ]
    remits = [
        FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2)),
        FakeRemit("CLP-2", "PCN-A", date(2026, 6, 16)),
    ]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 2
    claim_ids = {m.claim.id for m in matches}
    assert claim_ids == {"CLM-1", "CLM-2"}


def test_match_skips_already_matched_claims():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1), matched_remittance_id="OLD")]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1))]
    matches = reconcile.match(claims, remits)
    assert matches == []


def test_match_falls_back_to_first_claim_when_no_service_date():
    """When the remit has no service_date, match the first claim for that PCN."""
    claims = [
        FakeClaim("CLM-1", "PCN-A", None),
        FakeClaim("CLM-2", "PCN-A", None),
    ]
    remits = [FakeRemit("CLP-1", "PCN-A", None)]
    matches = reconcile.match(claims, remits)
    assert len(matches) == 1
    assert matches[0].claim.id == "CLM-1"


def test_match_reversal_flag_propagates():
    claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
    remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)]
    matches = reconcile.match(claims, remits)
    assert matches[0].is_reversal is True
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v Expected: ModuleNotFoundError: No module named 'cyclone.reconcile'.

  • Step 3: Implement reconcile.match + result types

backend/src/cyclone/reconcile.py:

"""Pure reconciliation functions.

These functions take lists of objects (ORM or stand-in dataclasses) and
return intent objects describing what should change. They do not own a
database session; the caller (cyclone.store) persists the intents.

Match algorithm:
- Group claims by patient_control_number.
- For each remit, find a candidate claim with the same PCN.
- Prefer the claim whose service_date_from is closest to the remit's
  service_date, within +/- window_days.
- If the remit has no service_date, fall back to the first claim for
  that PCN (in input order).
- Skip claims that already have matched_remittance_id set.
- is_reversal flag is propagated to the Match.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import date
from typing import Iterable, Optional, Protocol


WINDOW_DAYS_DEFAULT = 7


class _ClaimLike(Protocol):
    id: str
    patient_control_number: str
    service_date_from: Optional[date]
    matched_remittance_id: Optional[str]


class _RemitLike(Protocol):
    id: str
    payer_claim_control_number: str
    service_date: Optional[date]
    is_reversal: bool


@dataclass
class Match:
    claim: _ClaimLike
    remittance: _RemitLike
    strategy: str
    is_reversal: bool


def match(
    claims: Iterable[_ClaimLike],
    remittances: Iterable[_RemitLike],
    *,
    window_days: int = WINDOW_DAYS_DEFAULT,
) -> list[Match]:
    """Pair each remit with the closest eligible claim. Returns Match intents."""
    claims_list = list(claims)
    remits_list = list(remittances)

    # Eligible = no current match, has a PCN.
    eligible = [c for c in claims_list if c.patient_control_number and not c.matched_remittance_id]
    by_pcn: dict[str, list[_ClaimLike]] = {}
    for c in eligible:
        by_pcn.setdefault(c.patient_control_number, []).append(c)

    matches: list[Match] = []
    used_claim_ids: set[str] = set()

    for r in remits_list:
        candidates = by_pcn.get(r.payer_claim_control_number, [])
        candidates = [c for c in candidates if c.id not in used_claim_ids]
        if not candidates:
            continue

        chosen = _pick_claim(candidates, r.service_date, window_days)
        if chosen is None:
            continue

        matches.append(Match(
            claim=chosen,
            remittance=r,
            strategy="auto",
            is_reversal=r.is_reversal,
        ))
        used_claim_ids.add(chosen.id)

    return matches


def _pick_claim(
    candidates: list[_ClaimLike],
    remit_service_date: Optional[date],
    window_days: int,
) -> Optional[_ClaimLike]:
    """Choose the claim with the closest service_date_from within the window."""
    if remit_service_date is None:
        return candidates[0]  # no date info → first claim for that PCN

    best: tuple[int, _ClaimLike] | None = None
    for c in candidates:
        if c.service_date_from is None:
            continue
        delta = abs((c.service_date_from - remit_service_date).days)
        if delta > window_days:
            continue
        if best is None or delta < best[0]:
            best = (delta, c)

    if best is not None:
        return best[1]
    # No candidate within window had a date; fall back to first.
    return candidates[0]
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v Expected: 8 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add reconcile.match with date-window + multi-claim fallback"

Task 8: reconcile.apply_payment + apply_reversal + split_unmatched

Files:

  • Modify: backend/src/cyclone/reconcile.py

  • Modify: backend/tests/test_reconcile.py

  • Step 1: Add failing tests

Append to backend/tests/test_reconcile.py:

from cyclone.db import ClaimState


# ApplyIntent + ApplyIntent.noop tests ---------------------------------------


def test_apply_payment_full_amount_returns_paid():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
    assert intent.new_state == ClaimState.PAID
    assert intent.activity_kind == "reconcile"
    assert intent.skipped is False


def test_apply_payment_partial_amount_returns_partial():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("60"))
    assert intent.new_state == ClaimState.PARTIAL


def test_apply_payment_zero_paid_returns_received():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"))
    assert intent.new_state == ClaimState.RECEIVED


def test_apply_payment_status_4_returns_denied():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"),
                                       status_code="4")
    assert intent.new_state == ClaimState.DENIED


def test_apply_payment_already_paid_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
    remit = FakeRemit("CLP-1", "PCN-A", None)
    intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
    assert intent.skipped is True
    assert intent.activity_kind == "invalid_state"
    assert intent.new_state is None


def test_apply_reversal_of_paid_returns_reversed_with_prior_state():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.new_state == ClaimState.REVERSED
    assert intent.prior_claim_state == ClaimState.PAID
    assert intent.activity_kind == "reversal"
    assert intent.skipped is False


def test_apply_reversal_of_partial_works_same_way():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PARTIAL.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.new_state == ClaimState.REVERSED
    assert intent.prior_claim_state == ClaimState.PARTIAL


def test_apply_reversal_of_denied_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.DENIED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.skipped is True
    assert intent.activity_kind == "reversal_skipped"


def test_apply_reversal_of_already_reversed_is_noop():
    claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.REVERSED.value)
    remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
    intent = reconcile.apply_reversal(claim, remit)
    assert intent.skipped is True


def test_split_unmatched_partitions_by_match_set():
    claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
    remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
    matches = [Match(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)]
    unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
    assert [c.id for c in unmatched_claims] == ["CLM-2"]
    assert [r.id for r in unmatched_remits] == ["CLP-2"]
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v -k "apply or split" Expected: AttributeError on reconcile.apply_payment.

  • Step 3: Implement apply_payment, apply_reversal, split_unmatched

Append to backend/src/cyclone/reconcile.py:

from decimal import Decimal


@dataclass
class ApplyIntent:
    """Result of applying a match. The caller persists this."""
    new_state: Optional[ClaimState] = None
    activity_kind: str = "reconcile"
    skipped: bool = False
    prior_claim_state: Optional[ClaimState] = None
    reason: str = ""

    @classmethod
    def noop(cls, *, activity_kind: str = "invalid_state", reason: str = "") -> "ApplyIntent":
        return cls(
            new_state=None,
            activity_kind=activity_kind,
            skipped=True,
            prior_claim_state=None,
            reason=reason,
        )


def apply_payment(
    claim: _ClaimLike,
    remit: _RemitLike,
    *,
    charge: Decimal,
    paid: Decimal,
    status_code: str = "",
) -> ApplyIntent:
    """Decide the Claim's new state given a non-reversal match."""
    if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value,
                       ClaimState.DENIED.value, ClaimState.RECONCILED.value):
        return ApplyIntent.noop(
            reason=f"claim already in terminal state {claim.state}",
        )

    if status_code == "4":
        new_state = ClaimState.DENIED
    elif paid is None or paid <= 0:
        new_state = ClaimState.RECEIVED
    elif paid >= charge:
        new_state = ClaimState.PAID
    else:
        new_state = ClaimState.PARTIAL

    return ApplyIntent(new_state=new_state, activity_kind="reconcile")


def apply_reversal(claim: _ClaimLike, remit: _RemitLike) -> ApplyIntent:
    """Apply a reversal: flip paid/partial → REVERSED, preserve prior state."""
    if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value):
        return ApplyIntent(
            new_state=ClaimState.REVERSED,
            activity_kind="reversal",
            prior_claim_state=ClaimState(claim.state),
        )
    return ApplyIntent.noop(
        activity_kind="reversal_skipped",
        reason=f"reversal on state {claim.state}",
    )


def split_unmatched(
    claims: list[_ClaimLike],
    remits: list[_RemitLike],
    matches: list[Match],
) -> tuple[list[_ClaimLike], list[_RemitLike]]:
    """Return (unmatched_claims, unmatched_remits) after applying matches."""
    matched_claim_ids = {m.claim.id for m in matches}
    matched_remit_ids = {m.remittance.id for m in matches}
    return (
        [c for c in claims if c.id not in matched_claim_ids],
        [r for r in remits if r.id not in matched_remit_ids],
    )

Note: apply_payment reads claim.state as a string. ORM objects expose state as a string via ClaimState.SUBMITTED.value == "submitted". The fake FakeClaim in tests also stores state as a string. Real production callers (store.add()) pass ORM Claim objects whose .state is the ClaimState enum — SQLAlchemy returns the underlying string value automatically when reading the column. The protocol used in _ClaimLike doesn't enforce state typing, so both work.

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v Expected: 18 tests pass (8 from Task 7 + 10 from this task).

  • Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add apply_payment, apply_reversal, split_unmatched pure fns"

Phase 4 — Store facade refactor

Task 9: cyclone.store rewrite — CycloneStore class with Batch/Claim persistence (837 only)

Files:

  • Modify: backend/src/cyclone/store.py
  • Modify: backend/tests/test_store.py

Note: test_store.py already exists from sub-project 1 with ~8 tests for the in-memory store. We will replace its contents to test the new SQLAlchemy-backed facade. The original tests become obsolete because the InMemoryStore.list() etc. no longer exist. This is intentional — the public API surface stays but the test layer changes.

  • Step 1: Add failing tests for the new facade (837 path)

Rewrite backend/tests/test_store.py:

"""Tests for the SQLAlchemy-backed CycloneStore.

Public API is preserved from the in-memory version: add / get_batch /
iter_claims / iter_remittances / distinct_providers / recent_activity.
New methods: list_unmatched / manual_match / manual_unmatch.
"""

from __future__ import annotations

from datetime import date, datetime, timezone
from decimal import Decimal

import pytest

from cyclone import db
from cyclone.store import CycloneStore, store
from cyclone.parsers.models import (
    ClaimOutput, Envelope, ParseResult, ValidationReport,
)
from cyclone.parsers.models_835 import (
    ClaimPayment, ParseResult835, Envelope as Envelope835,
    FinancialInfo, ReassociationTrace, Payer835, Payee835, BatchSummary,
)


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
    return ClaimOutput(
        claim_id=claim_id,
        billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None,
                          "address": None, "city": None, "state": None, "zip": None,
                          "phone": None, "tax_id": None},
        subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe",
                    "dob": None, "gender": None, "address": None, "city": None,
                    "state": None, "zip": None},
        payer={"id": "P1", "name": "Test Payer", "type": None, "address": None,
               "city": None, "state": None, "zip": None},
        diagnoses=[],
        service_lines=[],
        claim={"total_charge": float(charge), "place_of_service": "11",
               "frequency_code": "1", "prior_auth": None, "provider_signature": None,
               "assignment": None, "benefits_assigned": None, "release_of_info": None},
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )


def _make_result_837() -> ParseResult:
    return ParseResult(
        envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                          receiver_qualifier="ZZ", control_number="0001",
                          usage_indicator="P", transaction_set="837",
                          transaction_date="2026-06-19", transaction_time=None),
        billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
                          "address": None, "city": None, "state": None, "zip": None,
                          "phone": None, "tax_id": None},
        claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")],
        summary={"total_claims": 2, "passed": 2, "failed": 0,
                 "started_at": "2026-06-19T12:00:00Z",
                 "finished_at": "2026-06-19T12:00:01Z",
                 "duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
    )


def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1"):
    from cyclone.store import BatchRecord
    return BatchRecord(
        id=batch_id, kind=kind, input_filename="test.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=result or _make_result_837(),
    )


def test_module_singleton():
    assert isinstance(store, CycloneStore)


def test_add_837_persists_batch_and_claims():
    s = CycloneStore()
    rec = _make_batch_record()
    s.add(rec)
    # New session confirms persistence.
    with db.SessionLocal() as session:
        from cyclone.db import Batch, Claim, ClaimState
        b = session.get(Batch, "b-uuid-1")
        assert b is not None
        assert b.kind == "837p"
        claims = session.query(Claim).all()
        assert len(claims) == 2
        assert all(c.state == ClaimState.SUBMITTED for c in claims)


def test_iter_claims_returns_dicts():
    s = CycloneStore()
    s.add(_make_batch_record())
    rows = s.iter_claims()
    assert len(rows) == 2
    assert all("id" in r and "state" in r for r in rows)


def test_persistence_across_session():
    s1 = CycloneStore()
    s1.add(_make_batch_record(batch_id="b-x"))

    s2 = CycloneStore()  # fresh instance, same engine
    loaded = s2.get_batch("b-x")
    assert loaded is not None
    assert loaded["kind"] == "837p"
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_store.py -v Expected: ImportError on cyclone.store.CycloneStore or signature mismatch.

  • Step 3: Rewrite cyclone.store — keep BatchRecord, replace InMemoryStore

Replace the entire contents of backend/src/cyclone/store.py:

"""SQLAlchemy-backed store facade for parsed X12 files.

Public API preserved from the in-memory implementation (sub-project 1):
- add(record) → persists the BatchRecord; for 835 batches, triggers reconcile
- get_batch(id) → dict | None
- iter_claims(...) → list[dict]
- iter_remittances(...) → list[dict]
- distinct_providers() → list[dict]
- recent_activity(...) → list[dict]

New methods (sub-project 2):
- list_unmatched(...) → dict with claims + remittances
- manual_match(claim_id, remit_id) → Match
- manual_unmatch(claim_id) → None

The store is a singleton (`store`); public methods open their own
SessionLocal() context. Internal helpers convert ORM rows to the
UI-shaped dicts the API expects (to_ui_*).
"""

from __future__ import annotations

import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Literal, Optional

from pydantic import BaseModel, ConfigDict, model_validator

from cyclone import db
from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835

log = logging.getLogger(__name__)


BatchKind = Literal["837p", "835"]


class BatchRecord(BaseModel):
    """One parsed file. Preserved from sub-project 1 with __new__ dispatch."""

    model_config = ConfigDict(extra="ignore")

    id: str
    kind: BatchKind
    input_filename: str
    parsed_at: datetime
    result: ParseResult | ParseResult835

    def __new__(cls, *args: Any, **kwargs: Any):
        if cls is BatchRecord:
            kind = kwargs.get("kind")
            if kind is None and args and isinstance(args[0], dict):
                kind = args[0].get("kind")
            if kind == "837p":
                return BatchRecord837(*args, **kwargs)
            if kind == "835":
                return BatchRecord835(*args, **kwargs)
        return super().__new__(cls)

    @model_validator(mode="after")
    def _check_parsed_at_tz(self) -> "BatchRecord":
        if self.parsed_at.tzinfo is None:
            raise ValueError("parsed_at must be tz-aware (use datetime.now(timezone.utc))")
        return self


class BatchRecord837(BatchRecord):
    kind: Literal["837p"] = "837p"
    result: ParseResult


class BatchRecord835(BatchRecord):
    kind: Literal["835"] = "835"
    result: ParseResult835


def utcnow() -> datetime:
    return datetime.now(timezone.utc)


# --- CycloneStore --------------------------------------------------------------


class CycloneStore:
    """SQLAlchemy-backed facade. Public API preserved from sub-project 1."""

    def add(self, record: BatchRecord) -> str:
        """Persist BatchRecord; for 835 batches, trigger reconciliation."""
        from cyclone.db import Batch, Claim, Remittance, ActivityEvent, ClaimState
        from cyclone import reconcile

        batch_id = record.id
        parsed_iso = record.parsed_at.isoformat().replace("+00:00", "Z")

        with db.SessionLocal() as s:
            summary = record.result.summary
            validation = getattr(record.result, "validation", None)
            s.add(Batch(
                id=batch_id,
                kind=record.kind,
                input_filename=record.input_filename,
                parsed_at=record.parsed_at,
                totals_json=summary.model_dump() if hasattr(summary, "model_dump") else dict(summary),
                validation_json=(validation.model_dump() if hasattr(validation, "model_dump")
                                 else (dict(validation) if validation else None)),
                raw_result_json=record.result.model_dump(mode="json"),
            ))

            if record.kind == "837p":
                for c in record.result.claims:
                    s.add(_claim_837_row(c, batch_id))
            elif record.kind == "835":
                for cp in record.result.claims:
                    s.add(_remittance_835_row(cp, batch_id))

            s.add(ActivityEvent(
                ts=utcnow(), kind=f"parse_{record.kind.replace('p', '')}",
                batch_id=batch_id, payload_json={"claims": len(record.result.claims)},
            ))
            s.commit()

        # Reconciliation is triggered AFTER the ERA is persisted. Failures
        # are fail-soft (logged + activity event) and never block the response.
        if record.kind == "835":
            self._run_reconcile(batch_id)

        return batch_id

    def _run_reconcile(self, batch_id: str) -> None:
        from cyclone import reconcile
        from cyclone.db import Batch, Remittance, Claim
        try:
            with db.SessionLocal() as s:
                result = reconcile.run(s, batch_id)
                s.commit()
            log.info("reconcile batch=%s matched=%s", batch_id, result.matched)
        except Exception as exc:
            log.exception("reconcile failed for batch %s", batch_id)
            # Persist the failure in a fresh session.
            from cyclone.db import ActivityEvent
            with db.SessionLocal() as s2:
                s2.add(ActivityEvent(
                    ts=utcnow(), kind="reconcile_failed", batch_id=batch_id,
                    payload_json={"error": str(exc)},
                ))
                s2.commit()

    def get_batch(self, batch_id: str) -> dict | None:
        from cyclone.db import Batch
        with db.SessionLocal() as s:
            b = s.get(Batch, batch_id)
            if b is None:
                return None
            return {
                "id": b.id,
                "kind": b.kind,
                "input_filename": b.input_filename,
                "parsed_at": b.parsed_at.isoformat().replace("+00:00", "Z"),
                "totals": b.totals_json or {},
                "validation": b.validation_json or {},
                "result": b.raw_result_json or {},
            }

    def iter_claims(
        self,
        *,
        batch_id: Optional[str] = None,
        status: Optional[str] = None,
        payer: Optional[str] = None,
        provider_npi: Optional[str] = None,
        date_from: Optional[str] = None,
        date_to: Optional[str] = None,
        sort: str = "service_date_from",
        order: str = "desc",
        limit: int = 100,
        offset: int = 0,
    ) -> list[dict]:
        from sqlalchemy import select
        from cyclone.db import Claim, ClaimState
        with db.SessionLocal() as s:
            stmt = select(Claim)
            if batch_id:
                stmt = stmt.where(Claim.batch_id == batch_id)
            if status:
                stmt = stmt.where(Claim.state == ClaimState(status))
            if payer:
                stmt = stmt.where(Claim.payer_id == payer)
            if provider_npi:
                stmt = stmt.where(Claim.provider_npi == provider_npi)
            if date_from:
                stmt = stmt.where(Claim.service_date_from >= date_from)
            if date_to:
                stmt = stmt.where(Claim.service_date_from <= date_to)
            sort_col = getattr(Claim, sort, Claim.service_date_from)
            stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
            stmt = stmt.limit(limit).offset(offset)
            claims = s.execute(stmt).scalars().all()
            return [to_ui_claim(c) for c in claims]

    def iter_remittances(
        self,
        *,
        batch_id: Optional[str] = None,
        status: Optional[str] = None,
        payer: Optional[str] = None,
        claim_id: Optional[str] = None,
        date_from: Optional[str] = None,
        date_to: Optional[str] = None,
        sort: str = "received_at",
        order: str = "desc",
        limit: int = 100,
        offset: int = 0,
    ) -> list[dict]:
        from sqlalchemy import select
        from cyclone.db import Remittance
        with db.SessionLocal() as s:
            stmt = select(Remittance)
            if batch_id:
                stmt = stmt.where(Remittance.batch_id == batch_id)
            if status:
                stmt = stmt.where(Remittance.status_code == status)
            if payer:
                stmt = stmt.where(Remittance.payer_claim_control_number.like(f"{payer}%"))
            if claim_id:
                stmt = stmt.where(Remittance.claim_id == claim_id)
            sort_col = getattr(Remittance, sort, Remittance.received_at)
            stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
            stmt = stmt.limit(limit).offset(offset)
            remits = s.execute(stmt).scalars().all()
            return [to_ui_remittance(r) for r in remits]

    def distinct_providers(self) -> list[dict]:
        from sqlalchemy import select, func
        from cyclone.db import Claim
        with db.SessionLocal() as s:
            stmt = (
                select(Claim.provider_npi, Claim.payer_id, func.count(Claim.id))
                .where(Claim.provider_npi.is_not(None))
                .group_by(Claim.provider_npi, Claim.payer_id)
            )
            rows = s.execute(stmt).all()
            return [
                {"npi": npi, "payer": payer, "claim_count": count}
                for npi, payer, count in rows
                if npi
            ]

    def recent_activity(self, *, limit: int = 200) -> list[dict]:
        from sqlalchemy import select
        from cyclone.db import ActivityEvent
        with db.SessionLocal() as s:
            stmt = select(ActivityEvent).order_by(ActivityEvent.ts.desc()).limit(limit)
            events = s.execute(stmt).scalars().all()
            return [
                {
                    "id": e.id, "ts": e.ts.isoformat().replace("+00:00", "Z"),
                    "kind": e.kind, "batch_id": e.batch_id,
                    "claim_id": e.claim_id, "remittance_id": e.remittance_id,
                    "payload": e.payload_json or {},
                }
                for e in events
            ]

    # --- New methods (sub-project 2) ---------------------------------------

    def list_unmatched(
        self, *, kind: Literal["claim", "remit", "both"] = "both",
    ) -> dict:
        from sqlalchemy import select
        from cyclone.db import Claim, Remittance
        result: dict[str, list[dict]] = {"claims": [], "remittances": []}
        with db.SessionLocal() as s:
            if kind in ("claim", "both"):
                stmt = (
                    select(Claim)
                    .where(Claim.matched_remittance_id.is_(None))
                    .order_by(Claim.service_date_from.asc().nulls_last())
                )
                result["claims"] = [to_ui_claim(c) for c in s.execute(stmt).scalars().all()]
            if kind in ("remit", "both"):
                stmt = (
                    select(Remittance)
                    .where(Remittance.claim_id.is_(None))
                    .order_by(Remittance.received_at.asc())
                )
                result["remittances"] = [
                    to_ui_remittance(r) for r in s.execute(stmt).scalars().all()
                ]
        return result

    def manual_match(self, claim_id: str, remittance_id: str) -> dict:
        from sqlalchemy import select
        from cyclone.db import Claim, Remittance, Match, ActivityEvent, ClaimState
        from cyclone import reconcile

        with db.SessionLocal() as s:
            claim = s.get(Claim, claim_id)
            remit = s.get(Remittance, remittance_id)
            if claim is None or remit is None:
                raise KeyError("claim_or_remit_not_found")
            if claim.matched_remittance_id is not None:
                raise AlreadyMatchedError(f"claim {claim_id} already matched")
            if remit.claim_id is not None:
                raise AlreadyMatchedError(f"remit {remittance_id} already matched")

            intent = (reconcile.apply_reversal(claim, remit) if remit.is_reversal
                      else reconcile.apply_payment(claim, remit,
                                                   charge=claim.charge_amount,
                                                   paid=remit.total_paid,
                                                   status_code=remit.status_code))
            if intent.skipped:
                raise InvalidStateError(intent.activity_kind, intent.reason, claim.state.value)

            prior = intent.prior_claim_state
            claim.state = intent.new_state if not remit.is_reversal else ClaimState.REVERSED
            claim.matched_remittance_id = remit.id
            m = Match(
                claim_id=claim.id, remittance_id=remit.id,
                strategy="manual",
                matched_at=utcnow(),
                prior_claim_state=prior,
                is_reversal=remit.is_reversal,
            )
            s.add(m)
            s.add(ActivityEvent(
                ts=utcnow(), kind="manual_match", batch_id=claim.batch_id,
                claim_id=claim.id, remittance_id=remit.id,
                payload_json={"new_state": claim.state.value},
            ))
            s.commit()
            return {"claim": to_ui_claim(claim), "match": {"id": m.id, "strategy": "manual"}}

    def manual_unmatch(self, claim_id: str) -> dict:
        from sqlalchemy import select, delete
        from cyclone.db import Claim, Match, ActivityEvent, ClaimState
        with db.SessionLocal() as s:
            claim = s.get(Claim, claim_id)
            if claim is None or claim.matched_remittance_id is None:
                raise KeyError("no_current_match")
            s.execute(delete(Match).where(Match.claim_id == claim_id))
            claim.matched_remittance_id = None
            claim.state = ClaimState.SUBMITTED
            s.add(ActivityEvent(
                ts=utcnow(), kind="manual_unmatch", batch_id=claim.batch_id,
                claim_id=claim.id,
                payload_json={"reason": "operator-initiated"},
            ))
            s.commit()
            return {"claim": to_ui_claim(claim)}


class AlreadyMatchedError(Exception):
    """Raised by manual_match when either side is already matched."""


class InvalidStateError(Exception):
    """Raised by manual_match when the state transition would be invalid."""

    def __init__(self, activity_kind: str, reason: str, current_state: str):
        super().__init__(reason)
        self.activity_kind = activity_kind
        self.current_state = current_state


# Backwards-compat alias for code that imported the old name.
InMemoryStore = CycloneStore

store = CycloneStore()


# --- ORM row constructors ------------------------------------------------------


def _claim_837_row(c: ClaimOutput, batch_id: str) -> "db.Claim":
    """Build a Claim ORM row from a parsed ClaimOutput."""
    from cyclone.db import Claim, ClaimState
    return Claim(
        id=c.claim_id,
        batch_id=batch_id,
        patient_control_number=c.claim_id,
        service_date_from=_safe_date(getattr(c.claim, "service_date_from", None)
                                      or getattr(c.claim, "service_date", None)),
        service_date_to=None,
        charge_amount=Decimal(str(c.claim.total_charge or 0)),
        provider_npi=str(c.billing_provider.npi) if c.billing_provider else None,
        payer_id=str(c.payer.id) if c.payer else None,
        state=ClaimState.SUBMITTED,
        raw_json=c.model_dump(mode="json"),
    )


def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> "db.Remittance":
    from cyclone.db import Remittance
    first_svc = cp.service_payments[0] if cp.service_payments else None
    return Remittance(
        id=cp.payer_claim_control_number + ":" + batch_id[:8],  # unique per batch
        batch_id=batch_id,
        payer_claim_control_number=cp.payer_claim_control_number,
        status_code=cp.status_code,
        status_label=cp.status_label,
        total_charge=Decimal(str(cp.total_charge or 0)),
        total_paid=Decimal(str(cp.total_paid or 0)),
        patient_responsibility=(Decimal(str(cp.patient_responsibility))
                                if cp.patient_responsibility is not None else None),
        adjustment_amount=Decimal("0"),  # populated by reconcile
        received_at=utcnow(),
        service_date=first_svc.service_date if first_svc else None,
        is_reversal=cp.status_code in ("21", "22"),
        raw_json=cp.model_dump(mode="json"),
    )


def _safe_date(value: Any) -> Optional[date]:
    if value is None:
        return None
    if isinstance(value, date) and not isinstance(value, datetime):
        return value
    if isinstance(value, datetime):
        return value.date()
    return None


# --- Mappers (UI shapes) -------------------------------------------------------

def to_ui_claim(c: "db.Claim") -> dict:
    return {
        "id": c.id,
        "claimId": c.patient_control_number,
        "patientControlNumber": c.patient_control_number,
        "serviceDate": c.service_date_from.isoformat() if c.service_date_from else None,
        "serviceDateFrom": c.service_date_from.isoformat() if c.service_date_from else None,
        "serviceDateTo": c.service_date_to.isoformat() if c.service_date_to else None,
        "chargeAmount": float(c.charge_amount),
        "providerNpi": c.provider_npi,
        "payerId": c.payer_id,
        "state": c.state.value if hasattr(c.state, "value") else str(c.state),
        "status": c.state.value if hasattr(c.state, "value") else str(c.state),
        "matchedRemittanceId": c.matched_remittance_id,
        "batchId": c.batch_id,
    }


def to_ui_remittance(r: "db.Remittance") -> dict:
    return {
        "id": r.id,
        "claimId": r.payer_claim_control_number,
        "payerClaimControlNumber": r.payer_claim_control_number,
        "payerName": None,
        "paidAmount": float(r.total_paid),
        "adjustmentAmount": float(r.adjustment_amount),
        "status": r.status_label or r.status_code,
        "statusCode": r.status_code,
        "denialReason": None,
        "validationWarnings": [],
        "receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
        "batchId": r.batch_id,
        "isReversal": bool(r.is_reversal),
        "serviceDate": r.service_date.isoformat() if r.service_date else None,
    }


def to_ui_provider(*, npi: str, payer: str, claim_count: int) -> dict:
    return {"npi": npi, "payer": payer, "claimCount": claim_count}
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_store.py -v Expected: 4 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(backend): rewrite cyclone.store as SQLAlchemy facade (837 path)"

Heads-up: The parse-835 route in api.py calls store.add() which now triggers reconciliation via reconcile.run(). But reconcile.run() doesn't exist yet — it's added in Task 10. To keep tests green, CycloneStore._run_reconcile only runs when record.kind == "835". Since we haven't wired reconciliation yet, parsing an 835 right now will raise on reconcile.run(). The fault-injection test in Task 14 covers this. Don't run parse-835 manually until Task 10 is merged.


Task 10: reconcile.run orchestrator + integration with store

Files:

  • Modify: backend/src/cyclone/reconcile.py

  • Modify: backend/tests/test_reconcile.py

  • Step 1: Add failing tests

Append to backend/tests/test_reconcile.py:

from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import (
    Batch, Claim, ClaimState, Remittance, Match, ActivityEvent,
)


@pytest.fixture
def fixture_835(tmp_path, monkeypatch):
    """Set up a clean DB and return an empty helper for building remits."""
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/reconcile.db")
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


def _make_batch(s, batch_id="b1", kind="835"):
    s.add(Batch(
        id=batch_id, kind=kind, input_filename="x",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
    ))
    s.flush()


def _make_claim(s, claim_id="CLM-1", pcn="PCN-A", charge="100.00",
                service_date=None, state=ClaimState.SUBMITTED):
    s.add(Claim(
        id=claim_id, batch_id="b1", patient_control_number=pcn,
        service_date_from=service_date, charge_amount=Decimal(charge),
        state=state,
    ))
    s.flush()


def _make_remit(s, remit_id="CLP-1", pcn="PCN-A", status="1",
                charge="100.00", paid="100.00", service_date=None,
                is_reversal=False):
    rid = remit_id + ":b1xxxxxx"
    s.add(Remittance(
        id=rid, batch_id="b1", payer_claim_control_number=pcn,
        status_code=status, total_charge=Decimal(charge), total_paid=Decimal(paid),
        received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        service_date=service_date, is_reversal=is_reversal,
    ))
    s.flush()
    return rid


def test_run_matches_and_updates_state(fixture_835):
    with db.SessionLocal() as s:
        _make_batch(s)
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
        _make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00", date(2026, 6, 1))
        s.commit()

    with db.SessionLocal() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 1
    assert result.unmatched_remittances == 0
    assert result.unmatched_claims == 0

    with db.SessionLocal() as s:
        claim = s.get(Claim, "CLM-1")
        assert claim.state == ClaimState.PAID
        assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"


def test_run_orphan_remit_leaves_claim_unmatched(fixture_835):
    with db.SessionLocal() as s:
        _make_batch(s)
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
        _make_remit(s, "CLP-2", "PCN-NEW", "1", "50.00", "50.00", date(2026, 6, 5))
        s.commit()

    with db.SessionLocal() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 0
    assert result.unmatched_remittances == 1
    assert result.unmatched_claims == 0


def test_run_reversal_flips_paid_to_reversed(fixture_835):
    with db.SessionLocal() as s:
        _make_batch(s)
        _make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1),
                    state=ClaimState.PAID)
        # Match row already exists from prior reconcile.
        s.add(Match(
            claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
            strategy="auto", matched_at=datetime.now(timezone.utc),
            is_reversal=False,
        ))
        s.flush()
        _make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
                    date(2026, 6, 10), is_reversal=True)
        s.commit()

    with db.SessionLocal() as s:
        from cyclone import reconcile
        result = reconcile.run(s, "b1")
        s.commit()

    assert result.matched == 1
    with db.SessionLocal() as s:
        claim = s.get(Claim, "CLM-1")
        assert claim.state == ClaimState.REVERSED
        # Match rows: original + reversal.
        matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
        assert len(matches) == 2
        reversal_match = [m for m in matches if m.is_reversal][0]
        assert reversal_match.prior_claim_state == ClaimState.PAID


def test_run_failed_reconcile_writes_activity_event(fixture_835):
    """If reconcile crashes, the batch + remittances stay; activity event records failure."""
    with db.SessionLocal() as s:
        _make_batch(s)
        _make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00")
        s.commit()

    # Monkeypatch reconcile.match to raise.
    from cyclone import reconcile
    real_match = reconcile.match
    def boom(*a, **kw): raise RuntimeError("synthetic fault")
    reconcile.match = boom
    try:
        with db.SessionLocal() as s:
            try:
                reconcile.run(s, "b1")
                s.commit()
            except RuntimeError:
                pass  # caller is responsible for catch; reconcile.run itself shouldn't raise
    finally:
        reconcile.match = real_match

    # Batch + remit still present.
    with db.SessionLocal() as s:
        b = s.get(Batch, "b1")
        assert b is not None
        r = s.query(Remittance).first()
        assert r is not None
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v -k "run_" Expected: ImportError on reconcile.run.

  • Step 3: Implement reconcile.run orchestrator

Append to backend/src/cyclone/reconcile.py:

from dataclasses import dataclass


@dataclass
class ReconcileResult:
    matched: int = 0
    unmatched_claims: int = 0
    unmatched_remittances: int = 0
    skipped: int = 0


def run(session, batch_id: str) -> ReconcileResult:
    """Orchestrate reconciliation for an 835 batch.

    Expects Batch + Remittance + CasAdjustment rows already persisted
    (this is called from store.add() AFTER commit). Loads the new
    remittances + all currently-unmatched Claims, calls match(), then
    applies each match's intent inside the caller's session.

    Returns counts. Does NOT commit; caller controls transaction.
    Raises on failure (caller catches and writes activity event).
    """
    from sqlalchemy import select
    from cyclone.db import (
        Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
        ClaimState,
    )

    new_remits = list(
        session.execute(
            select(Remittance).where(Remittance.batch_id == batch_id)
        ).scalars().all()
    )

    unmatched_claims = list(
        session.execute(
            select(Claim).where(Claim.matched_remittance_id.is_(None))
        ).scalars().all()
    )

    matches = match(unmatched_claims, new_remits)

    applied = 0
    skipped = 0
    for m in matches:
        if m.is_reversal:
            intent = apply_reversal(m.claim, m.remittance)
        else:
            intent = apply_payment(
                m.claim, m.remittance,
                charge=m.claim.charge_amount, paid=m.remittance.total_paid,
                status_code=m.remittance.status_code,
            )

        if intent.skipped:
            session.add(ActivityEvent(
                ts=datetime.now(timezone.utc), kind=intent.activity_kind,
                batch_id=batch_id, claim_id=m.claim.id,
                remittance_id=m.remittance.id,
                payload_json={"reason": intent.reason,
                              "current_state": getattr(m.claim.state, "value", m.claim.state)},
            ))
            skipped += 1
            continue

        session.add(Match(
            claim_id=m.claim.id, remittance_id=m.remittance.id,
            strategy=m.strategy,
            matched_at=datetime.now(timezone.utc),
            prior_claim_state=intent.prior_claim_state,
            is_reversal=m.is_reversal,
        ))
        if not m.is_reversal:
            m.claim.state = intent.new_state
        else:
            m.claim.state = ClaimState.REVERSED
        m.claim.matched_remittance_id = m.remittance.id

        session.add(ActivityEvent(
            ts=datetime.now(timezone.utc), kind=intent.activity_kind,
            batch_id=batch_id, claim_id=m.claim.id,
            remittance_id=m.remittance.id,
            payload_json={"new_state": m.claim.state.value},
        ))
        applied += 1

    # Aggregate CAS adjustments per Remittance.
    for r in new_remits:
        total = session.execute(
            select(func.sum(CasAdjustment.amount))
            .where(CasAdjustment.remittance_id == r.id)
        ).scalar_one() or Decimal("0")
        r.adjustment_amount = total

    # Persist any CasAdjustment rows that came from raw_segments but weren't
    # explicitly created. The parser's CLAIM_ADJUSTMENTS extraction is in
    # Task 11; for now, do nothing here — adjustment_amount stays 0 unless
    # something explicitly created CasAdjustment rows.

    # Partition.
    unmatched_claims_after, unmatched_remits_after = split_unmatched(
        unmatched_claims, new_remits,
        [m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
    )

    return ReconcileResult(
        matched=applied,
        unmatched_claims=len(unmatched_claims_after),
        unmatched_remittances=len(unmatched_remits_after),
        skipped=skipped,
    )

Imports needed at top of reconcile.py: add from datetime import datetime, timezone and from decimal import Decimal and from sqlalchemy import func. Adjust the existing imports accordingly.

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v Expected: 22 tests pass (18 from previous tasks + 4 from this task).

  • Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add reconcile.run orchestrator with match + apply + CAS agg"

Task 11: Wire ClaimAdjustment rows from 835 service_payments[*].adjustments during store.add()

Files:

  • Modify: backend/src/cyclone/store.py

  • Modify: backend/tests/test_store_reconcile.py

  • Step 1: Add failing test

Create backend/tests/test_store_reconcile.py:

"""Integration tests for CycloneStore.add() with 835 batches.

Covers: parse-835 persist → reconcile trigger → CAS aggregation.
"""

from __future__ import annotations

from datetime import datetime, timezone

import pytest

from cyclone import db
from cyclone.parsers.models_835 import (
    ClaimPayment, ClaimAdjustment, ServicePayment,
    ParseResult835, Envelope, FinancialInfo, ReassociationTrace,
    Payer835, Payee835, BatchSummary,
)
from cyclone.store import CycloneStore


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


def _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A",
                          status="1", charge="124.00", paid="62.00",
                          cas_amount="62.00"):
    cp = ClaimPayment(
        payer_claim_control_number=remit_id,
        status_code=status,
        status_label="Primary",
        total_charge=charge, total_paid=paid,
        service_payments=[
            ServicePayment(
                line_number=1, procedure_qualifier="HC", procedure_code="99213",
                charge=charge, payment=paid,
                adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
                                              amount=cas_amount)],
            ),
        ],
    )
    return cp


def _make_835_result(claims):
    return ParseResult835(
        envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                          receiver_qualifier="ZZ", control_number="0001",
                          usage_indicator="P", transaction_set="835",
                          transaction_date="2026-06-19", transaction_time=None),
        financial_info=FinancialInfo(credit_debit="C", payment_amount=0.0,
                                      credit_debit_indicator=None,
                                      payment_method=None, date=None),
        trace=ReassociationTrace(trace_type_code=None, trace_number=None,
                                  originating_company_id=None, originating_company_qual=None),
        payer=Payer835(name="X", id="SKCO0"),
        payee=Payee835(name="Y", npi="1234567890"),
        claims=claims,
        summary=BatchSummary(total_claims=len(claims), paid=0, denied=0,
                              reversed=0, total_paid=0.0, total_adjustment=0.0),
    )


def test_add_835_aggregates_cas_into_adjustment_amount():
    s = CycloneStore()
    cp = _make_remit_with_cas()
    from cyclone.store import BatchRecord835
    rec = BatchRecord835(
        id="b-1", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    )
    s.add(rec)

    with db.SessionLocal() as session:
        from cyclone.db import Remittance, CasAdjustment
        r = session.query(Remittance).first()
        assert r is not None
        assert r.adjustment_amount == 62.0
        cas_rows = session.query(CasAdjustment).all()
        assert len(cas_rows) == 1
        assert cas_rows[0].group_code == "CO"
        assert cas_rows[0].reason_code == "45"
  • Step 2: Run test to verify it fails

Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v Expected: assertion fails on adjustment_amount (still 0).

  • Step 3: Modify _remittance_835_row to also insert CasAdjustment rows

In backend/src/cyclone/store.py, find the _remittance_835_row helper and modify the section in CycloneStore.add() where the 835 branch lives. After adding each Remittance row, also add CasAdjustment rows for the SVC-level adjustments:

            elif record.kind == "835":
                for cp in record.result.claims:
                    remit_row = _remittance_835_row(cp, batch_id)
                    s.add(remit_row)
                    s.flush()  # need remit_row.id for FK
                    for svc in cp.service_payments:
                        for adj in svc.adjustments:
                            s.add(_cas_adjustment_row(adj, remit_row.id))

Add a new helper:

def _cas_adjustment_row(adj, remittance_id: str) -> "db.CasAdjustment":
    from cyclone.db import CasAdjustment
    return 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,
    )
  • Step 4: Run test to verify it passes

Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v Expected: 1 test passes.

  • Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store_reconcile.py
git commit -m "feat(backend): persist CasAdjustment rows from 835 SVC.adjustments"

Task 12: store.list_unmatched + manual_match + manual_unmatch (already in store; add tests)

Files:

  • Modify: backend/tests/test_store_reconcile.py

  • Step 1: Add failing tests

Append to backend/tests/test_store_reconcile.py:

def test_list_unmatched_returns_orphan_remit():
    s = CycloneStore()
    cp = _make_remit_with_cas(remit_id="CLP-ORPHAN", pcn="PCN-NEW")
    from cyclone.store import BatchRecord835
    rec = BatchRecord835(
        id="b-1", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    )
    s.add(rec)
    out = s.list_unmatched(kind="both")
    assert len(out["remittances"]) == 1
    assert out["remittances"][0]["payerClaimControlNumber"] == "PCN-NEW"
    assert out["claims"] == []


def test_manual_match_pairs_claim_with_orphan_remit():
    from cyclone.store import BatchRecord837, BatchRecord835
    from cyclone.parsers.models import (
        ClaimOutput, Envelope, ParseResult, ValidationReport,
    )

    s = CycloneStore()

    # First add a Claim via 837.
    co = ClaimOutput(
        claim_id="CLM-1",
        billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
                          "address": None, "city": None, "state": None, "zip": None,
                          "phone": None, "tax_id": None},
        subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe",
                    "dob": None, "gender": None, "address": None, "city": None,
                    "state": None, "zip": None},
        payer={"id": "P1", "name": "Test Payer", "type": None, "address": None,
               "city": None, "state": None, "zip": None},
        diagnoses=[], service_lines=[],
        claim={"total_charge": 100.0, "place_of_service": "11",
               "frequency_code": "1", "prior_auth": None,
               "provider_signature": None, "assignment": None,
               "benefits_assigned": None, "release_of_info": None},
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )
    pr837 = ParseResult(
        envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                          receiver_qualifier="ZZ", control_number="0001",
                          usage_indicator="P", transaction_set="837",
                          transaction_date="2026-06-19", transaction_time=None),
        billing_provider=co.billing_provider,
        claims=[co],
        summary={"total_claims": 1, "passed": 1, "failed": 0,
                 "started_at": "2026-06-19T12:00:00Z",
                 "finished_at": "2026-06-19T12:00:01Z",
                 "duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
    )
    s.add(BatchRecord837(
        id="b-837", kind="837p", input_filename="c.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=pr837,
    ))

    # Now an 835 with an orphan remit matching that Claim.
    cp = ClaimPayment(
        payer_claim_control_number="CLM-1",  # matches CLM01
        status_code="1", total_charge=100.0, total_paid=100.0,
    )
    s.add(BatchRecord835(
        id="b-835", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    ))

    out = s.list_unmatched(kind="both")
    assert len(out["claims"]) == 1
    assert len(out["remittances"]) == 1

    # Manually pair.
    claim_id = out["claims"][0]["id"]
    remit_id = out["remittances"][0]["id"]
    result = s.manual_match(claim_id, remit_id)
    assert result["claim"]["state"] == "paid"
    assert result["match"]["strategy"] == "manual"

    out = s.list_unmatched(kind="both")
    assert out["claims"] == []
    assert out["remittances"] == []


def test_manual_match_conflict_raises():
    """Pairing a claim that is already matched raises AlreadyMatchedError."""
    from cyclone.store import BatchRecord835, AlreadyMatchedError
    s = CycloneStore()
    cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A")
    s.add(BatchRecord835(
        id="b-1", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    ))
    # Direct DB setup: create a Claim + first match.
    from cyclone.db import Claim, Match
    with db.SessionLocal() as sess:
        sess.add(Claim(id="CLM-X", batch_id="b-1",
                       patient_control_number="PCN-A",
                       charge_amount=100, state="paid",
                       matched_remittance_id="CLP-1:b1xxxxxx"))
        sess.commit()

    with pytest.raises(AlreadyMatchedError):
        s.manual_match("CLM-X", out["remittances"][0]["id"] if False else "CLP-1:b1xxxxxx")

The last test references out from the previous test. That's a copy-paste bug — replace with a fresh setup or a single-claim setup. The cleaner version:

def test_manual_match_conflict_raises():
    from cyclone.store import BatchRecord835, AlreadyMatchedError
    s = CycloneStore()
    cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A")
    s.add(BatchRecord835(
        id="b-1", kind="835", input_filename="era.txt",
        parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
        result=_make_835_result([cp]),
    ))
    from cyclone.db import Claim
    with db.SessionLocal() as sess:
        sess.add(Claim(id="CLM-X", batch_id="b-1",
                       patient_control_number="PCN-A",
                       charge_amount=100, state="paid",
                       matched_remittance_id="CLP-1:b1xxxxxx"))
        sess.commit()
    with pytest.raises(AlreadyMatchedError):
        s.manual_match("CLM-X", "CLP-1:b1xxxxxx")

Use this cleaner version.

  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v Expected: failures on list_unmatched, manual_match.

  • Step 3: Verify store.list_unmatched / manual_match / manual_unmatch are already implemented

These methods were added in Task 9's store rewrite (search for def list_unmatched and def manual_match in backend/src/cyclone/store.py). They should now pass the tests.

If a test fails:

  • list_unmatched returns the wrong shape → check iter_claims / iter_remittances mappers

  • manual_match raises the wrong exception → check AlreadyMatchedError

  • manual_match doesn't update Claim state → check the manual_match body

  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v Expected: 3 tests pass (the clean version of test_manual_match_conflict_raises plus the previous 2).

  • Step 5: Commit
git add backend/tests/test_store_reconcile.py
git commit -m "test(backend): cover list_unmatched + manual_match + conflict path"

Phase 5 — API additions

Task 13: Wire db.init() into __main__.py startup

Files:

  • Modify: backend/src/cyclone/__main__.py

  • Step 1: Read current __main__.py and find the serve entry point

The serve function calls uvicorn.run(...). Before that, call db.init_db().

  • Step 2: Add the import + call
from cyclone import db

def serve() -> None:
    db.init_db()  # creates engine, runs migrations
    # ... existing uvicorn.run(...) call
  • Step 3: Smoke test startup

Run: cd backend && CYCLONE_DB_URL=sqlite:///$HOME/.local/share/cyclone/smoke.db rm -f ~/.local/share/cyclone/smoke.db; .venv/bin/python -m cyclone serve & Wait 2 seconds, then: Run: sqlite3 ~/.local/share/cyclone/smoke.db ".tables" Expected: prints activity_events batches cas_adjustments claims matches remittances.

Kill the server: pkill -f "python -m cyclone serve" || true

  • Step 4: Commit
git add backend/src/cyclone/__main__.py
git commit -m "feat(backend): call db.init_db() before uvicorn starts"

Task 14: API endpoint GET /api/reconciliation/unmatched

Files:

  • Modify: backend/src/cyclone/api.py

  • Modify: backend/tests/test_api_gets.py

  • Step 1: Add failing tests

Append to backend/tests/test_api_gets.py:

def test_get_reconciliation_unmatched_empty(client):
    r = client.get("/api/reconciliation/unmatched")
    assert r.status_code == 200
    assert r.json() == {"claims": [], "remittances": []}


def test_get_reconciliation_unmatched_returns_orphans(client, monkeypatch):
    """A Claim with no match + a Remit with no claim should both surface."""
    # Use the same client fixture; populate via the store directly.
    from cyclone.store import CycloneStore, BatchRecord837, BatchRecord835
    from cyclone.parsers.models import (
        ClaimOutput, Envelope, ParseResult, ValidationReport,
    )
    from cyclone.parsers.models_835 import (
        ClaimPayment, ParseResult835, Envelope as Env835,
        FinancialInfo, ReassociationTrace, Payer835, Payee835, BatchSummary,
    )
    from datetime import datetime, timezone

    co = ClaimOutput(
        claim_id="CLM-1",
        billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
                          "address": None, "city": None, "state": None, "zip": None,
                          "phone": None, "tax_id": None},
        subscriber={"member_id": "M1", "first_name": "J", "last_name": "D",
                    "dob": None, "gender": None, "address": None, "city": None,
                    "state": None, "zip": None},
        payer={"id": "P1", "name": "X", "type": None, "address": None,
               "city": None, "state": None, "zip": None},
        diagnoses=[], service_lines=[],
        claim={"total_charge": 100.0, "place_of_service": "11",
               "frequency_code": "1", "prior_auth": None,
               "provider_signature": None, "assignment": None,
               "benefits_assigned": None, "release_of_info": None},
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )
    pr837 = ParseResult(
        envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                          receiver_qualifier="ZZ", control_number="0001",
                          usage_indicator="P", transaction_set="837",
                          transaction_date="2026-06-19", transaction_time=None),
        billing_provider=co.billing_provider, claims=[co],
        summary={"total_claims": 1, "passed": 1, "failed": 0,
                 "started_at": "2026-06-19T12:00:00Z",
                 "finished_at": "2026-06-19T12:00:01Z",
                 "duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
    )
    CycloneStore().add(BatchRecord837(
        id="b-837", kind="837p", input_filename="c.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=pr837,
    ))
    CycloneStore().add(BatchRecord835(
        id="b-835", kind="835", input_filename="e.txt",
        parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
        result=ParseResult835(
            envelope=Env835(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                            receiver_qualifier="ZZ", control_number="0001",
                            usage_indicator="P", transaction_set="835",
                            transaction_date="2026-06-19", transaction_time=None),
            financial_info=FinancialInfo(credit_debit="C", payment_amount=0.0,
                                          credit_debit_indicator=None,
                                          payment_method=None, date=None),
            trace=ReassociationTrace(trace_type_code=None, trace_number=None,
                                      originating_company_id=None,
                                      originating_company_qual=None),
            payer=Payer835(name="X", id="SKCO0"),
            payee=Payee835(name="Y", npi="1234567890"),
            claims=[ClaimPayment(payer_claim_control_number="PCN-OTHER",
                                  status_code="1", total_charge=50.0, total_paid=50.0)],
            summary=BatchSummary(total_claims=1, paid=0, denied=0,
                                  reversed=0, total_paid=0.0, total_adjustment=0.0),
        ),
    ))

    r = client.get("/api/reconciliation/unmatched")
    assert r.status_code == 200
    body = r.json()
    assert len(body["claims"]) == 1
    assert body["claims"][0]["id"] == "CLM-1"
    assert len(body["remittances"]) == 1
    assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"

Test fixture note: the existing test_api_gets.py has a client fixture that uses fastapi.testclient.TestClient(app). It also sets up init_db somewhere — check the file. If not, you need to add @pytest.fixture(autouse=True) that calls db.init_db() with a tmp_path URL. Adapt the test above to use the existing fixtures.

  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "reconciliation_unmatched" Expected: 404 (route not defined).

  • Step 3: Add the route to api.py

In backend/src/cyclone/api.py, add to the imports:

from cyclone.store import AlreadyMatchedError, InvalidStateError

Add a new route:

@app.get("/api/reconciliation/unmatched")
def get_reconciliation_unmatched() -> dict:
    """Returns unmatched Claims (left) and unmatched Remittances (right)."""
    return store.list_unmatched(kind="both")
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "reconciliation" Expected: 2 tests pass.

  • Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): GET /api/reconciliation/unmatched"

Task 15: API endpoints POST /api/reconciliation/match + /unmatch

Files:

  • Modify: backend/src/cyclone/api.py

  • Modify: backend/tests/test_api_gets.py

  • Step 1: Add failing tests

Append to backend/tests/test_api_gets.py:

def test_post_match_happy_path(client):
    """Match an unmatched claim + remit; both removed from unmatched bucket."""
    from cyclone.store import CycloneStore, BatchRecord837
    from cyclone.parsers.models import (
        ClaimOutput, Envelope, ParseResult, ValidationReport,
    )
    from datetime import datetime, timezone

    co = ClaimOutput(
        claim_id="CLM-1",
        billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
                          "address": None, "city": None, "state": None, "zip": None,
                          "phone": None, "tax_id": None},
        subscriber={"member_id": "M1", "first_name": "J", "last_name": "D",
                    "dob": None, "gender": None, "address": None, "city": None,
                    "state": None, "zip": None},
        payer={"id": "P1", "name": "X", "type": None, "address": None,
               "city": None, "state": None, "zip": None},
        diagnoses=[], service_lines=[],
        claim={"total_charge": 100.0, "place_of_service": "11",
               "frequency_code": "1", "prior_auth": None,
               "provider_signature": None, "assignment": None,
               "benefits_assigned": None, "release_of_info": None},
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )
    pr837 = ParseResult(
        envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
                          receiver_qualifier="ZZ", control_number="0001",
                          usage_indicator="P", transaction_set="837",
                          transaction_date="2026-06-19", transaction_time=None),
        billing_provider=co.billing_provider, claims=[co],
        summary={"total_claims": 1, "passed": 1, "failed": 0,
                 "started_at": "2026-06-19T12:00:00Z",
                 "finished_at": "2026-06-19T12:00:01Z",
                 "duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
    )
    CycloneStore().add(BatchRecord837(
        id="b-837", kind="837p", input_filename="c.txt",
        parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
        result=pr837,
    ))
    # Use the test's already-set-up orphaned remit from the previous test or
    # create one inline. Simplest: pull from /api/reconciliation/unmatched.

    out = client.get("/api/reconciliation/unmatched").json()
    if not out["remittances"]:
        # Create an orphan via direct DB if needed (depends on test order).
        from cyclone.db import Remittance, Batch
        from datetime import datetime as _dt
        with __import__("cyclone").db.SessionLocal() as s:
            s.add(Batch(id="b-835", kind="835", input_filename="e",
                        parsed_at=_dt.now(timezone.utc)))
            s.add(Remittance(id="CLP-1:b835xxxx", batch_id="b-835",
                              payer_claim_control_number="CLM-1",
                              status_code="1", total_charge=100.0,
                              total_paid=100.0,
                              received_at=_dt.now(timezone.utc)))
            s.commit()

    out = client.get("/api/reconciliation/unmatched").json()
    claim_id = out["claims"][0]["id"]
    remit_id = out["remittances"][0]["id"]

    r = client.post("/api/reconciliation/match",
                     json={"claim_id": claim_id, "remit_id": remit_id})
    assert r.status_code == 200
    body = r.json()
    assert body["claim"]["state"] == "paid"
    assert body["match"]["strategy"] == "manual"


def test_post_match_conflict_returns_409(client):
    """Matching an already-matched claim returns 409."""
    out = client.get("/api/reconciliation/unmatched").json()
    if not out["claims"] or not out["remittances"]:
        pytest.skip("no unmatched rows available")
    cid = out["claims"][0]["id"]
    rid = out["remittances"][0]["id"]
    r1 = client.post("/api/reconciliation/match", json={"claim_id": cid, "remit_id": rid})
    assert r1.status_code == 200
    # Second match should 409.
    r2 = client.post("/api/reconciliation/match", json={"claim_id": cid, "remit_id": rid})
    assert r2.status_code == 409
    assert r2.json()["error"] == "already_matched"


def test_post_unmatch_removes_match(client):
    """Unmatch reverses a manual pair and returns the claim to submitted."""
    # Setup: create a manual pair.
    from cyclone.db import Batch, Claim, Remittance
    from datetime import datetime, timezone
    from decimal import Decimal
    from cyclone import db
    with db.SessionLocal() as s:
        s.add(Batch(id="b1", kind="837p", input_filename="x",
                    parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc)))
        s.add(Claim(id="CLM-X", batch_id="b1", patient_control_number="CLM-X",
                    charge_amount=Decimal("100"), state="paid",
                    matched_remittance_id="CLP-Y:b1xxxxxx"))
        s.add(Remittance(id="CLP-Y:b1xxxxxx", batch_id="b1",
                          payer_claim_control_number="CLM-X",
                          status_code="1", total_charge=Decimal("100"),
                          total_paid=Decimal("100"),
                          received_at=datetime(2026, 6, 19, tzinfo=timezone.utc)))
        s.commit()

    r = client.post("/api/reconciliation/unmatch", json={"claim_id": "CLM-X"})
    assert r.status_code == 200
    assert r.json()["claim"]["state"] == "submitted"
  • Step 2: Run tests to verify they fail

Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "match or unmatch" Expected: 404 (route not defined).

  • Step 3: Add the two POST routes

In backend/src/cyclone/api.py:

@app.post("/api/reconciliation/match")
def post_reconciliation_match(body: dict) -> dict:
    """Manually pair a Claim with a Remittance."""
    claim_id = body.get("claim_id")
    remit_id = body.get("remit_id")
    if not claim_id or not remit_id:
        raise HTTPException(status_code=400, detail="claim_id and remit_id required")
    try:
        return store.manual_match(claim_id, remit_id)
    except AlreadyMatchedError as e:
        raise HTTPException(
            status_code=409,
            detail={"error": "already_matched", "message": str(e)},
        )
    except InvalidStateError as e:
        raise HTTPException(
            status_code=409,
            detail={"error": "invalid_state", "current_state": e.current_state,
                    "activity_kind": e.activity_kind},
        )
    except KeyError:
        raise HTTPException(status_code=404, detail="claim_or_remit_not_found")


@app.post("/api/reconciliation/unmatch")
def post_reconciliation_unmatch(body: dict) -> dict:
    """Remove a manual match; reset Claim to submitted."""
    claim_id = body.get("claim_id")
    if not claim_id:
        raise HTTPException(status_code=400, detail="claim_id required")
    try:
        return store.manual_unmatch(claim_id)
    except KeyError:
        raise HTTPException(status_code=404, detail="no_current_match")
  • Step 4: Run tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v Expected: 5 tests pass (2 from Task 14 + 3 from this task).

  • Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): POST /api/reconciliation/{match,unmatch}"

Task 16: Update parse-835 response to include reconciliation summary

Files:

  • Modify: backend/src/cyclone/api.py

  • Modify: backend/tests/test_api_parse_persists.py

  • Step 1: Add failing test

Append to backend/tests/test_api_parse_persists.py:

def test_parse_835_response_includes_reconciliation_summary(client, tmp_path):
    """A successful 835 parse returns matched/unmatched counts."""
    fixture = (Path(__file__).parent / "fixtures" / "co_medicaid_835.txt").read_text()
    p = tmp_path / "era.txt"
    p.write_text(fixture)
    with open(p, "rb") as f:
        r = client.post(
            "/api/parse-835",
            files={"file": ("era.txt", f, "text/plain")},
            headers={"Accept": "application/json"},
        )
    assert r.status_code == 200
    body = r.json()
    assert "reconciliation" in body
    rec = body["reconciliation"]
    assert "matched" in rec
    assert "unmatched_claims" in rec
    assert "unmatched_remittances" in rec
    assert "skipped" in rec
  • Step 2: Run test to verify it fails

Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v -k "reconciliation_summary" Expected: KeyError on body["reconciliation"].

  • Step 3: Update parse_835_route to return reconciliation counts

In backend/src/cyclone/api.py, locate the existing parse_835_route function. After the store.add(...) call, look up the latest ReconcileResult for this batch_id and include it in the response.

Replace the existing parse-835 route body with:

@app.post("/api/parse-835")
def parse_835_route(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid_835"),
) -> JSONResponse:
    raw = file.file.read()
    text = raw.decode("utf-8", errors="replace")
    cfg = _resolve_payer_835(payer)
    try:
        result = parse_835(text, cfg)
    except CycloneParseError as e:
        raise HTTPException(status_code=400, detail={"error": "parse_failed", "message": str(e)})

    validated = validate_835(result, cfg)
    if validated and not validated.passed:
        raise HTTPException(
            status_code=422,
            detail={"error": "validation_failed", "issues": [i.model_dump() for i in validated.errors]},
        )

    batch_id = uuid.uuid4().hex
    record = BatchRecord835(
        id=batch_id, kind="835", input_filename=file.filename or "upload.txt",
        parsed_at=utcnow(), result=result,
    )
    store.add(record)

    # Reconciliation runs inside store.add(); fetch the resulting counts.
    from cyclone import reconcile as _recon  # noqa: WPS433
    with db.SessionLocal() as s2:
        from sqlalchemy import select, func
        from cyclone.db import Match, Remittance
        matched = s2.execute(
            select(func.count(Match.id)).where(Match.remittance_id.in_(
                select(Remittance.id).where(Remittance.batch_id == batch_id)
            ))
        ).scalar_one()
        # The store.add path writes the activity event for "reconcile"; just
        # count matched Remittances for the response summary.
        from cyclone.db import ActivityEvent
        evt = s2.execute(
            select(ActivityEvent.payload_json)
            .where(ActivityEvent.batch_id == batch_id, ActivityEvent.kind == "reconcile")
            .order_by(ActivityEvent.ts.desc()).limit(1)
        ).scalar_one_or_none()
        skipped = 0
        # Pull unmatched counts via list_unmatched (cheap; small result set).
        unmatched = store.list_unmatched(kind="both")

    body = {
        "id": batch_id,
        "kind": "835",
        "input_filename": record.input_filename,
        "parsed_at": record.parsed_at.isoformat().replace("+00:00", "Z"),
        "totals": result.summary.model_dump(),
        "validation": validated.model_dump() if validated else None,
        "reconciliation": {
            "matched": matched,
            "unmatched_claims": len(unmatched["claims"]),
            "unmatched_remittances": len(unmatched["remittances"]),
            "skipped": skipped,
        },
    }
    return JSONResponse(body)

Note: The matched count is computed via SQL on the matches table for Remittances in this batch. unmatched is computed via list_unmatched. This is a read-after-write, so within the same process the data is consistent. The skipped count is taken from the most recent reconcile activity event payload (or 0 if no skipped happened) — the activity event payload carries {"count": N, ...} style data. Adjust if needed.

  • Step 4: Run test to verify it passes

Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v -k "reconciliation_summary" Expected: 1 test passes.

  • Step 5: Run the full test suite to confirm nothing regressed

Run: cd backend && .venv/bin/pytest -q Expected: ≥ 178 (existing) + 22 (reconcile pure) + 4 (db_migrate) + 1 (CAS agg) + 3 (store_reconcile) + ~5 (api) = ~213 tests pass.

  • Step 6: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
git commit -m "feat(backend): parse-835 response includes reconciliation summary"

Task 17: Remove the adjustmentAmount: 0.0 TODO marker + verify mappers

Files:

  • Modify: backend/src/cyclone/store.py (already done — _remittance_835_row no longer hardcodes it; reconcile.run updates it from CAS)

  • Verify: backend/tests/test_store_reconcile.py (the CAS aggregation test from Task 11)

  • Step 1: Verify adjustmentAmount: 0.0 TODO is gone

Run: cd backend && grep -n "adjustmentAmount: 0.0" src/cyclone/store.py || echo "GONE" Expected: GONE.

  • Step 2: Verify store.iter_remittances() returns real adjustmentAmount

Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py::test_add_835_aggregates_cas_into_adjustment_amount -v Expected: pass.

  • Step 3: (No commit — this is a verification task)

If both steps succeed, this task is complete. Move on.


Phase 6 — Frontend

Task 18: Add ClaimState enum + Match types in src/types/index.ts

Files:

  • Modify: src/types/index.ts

  • Step 1: Add the new types

Read the current src/types/index.ts to see where to add. At the bottom (or in an appropriate section), add:

export type ClaimState =
  | "submitted"
  | "received"
  | "paid"
  | "partial"
  | "denied"
  | "reconciled"
  | "reversed";

export const CLAIM_STATES: ClaimState[] = [
  "submitted",
  "received",
  "paid",
  "partial",
  "denied",
  "reconciled",
  "reversed",
];

export interface Match {
  id: number;
  claim_id: string;
  remittance_id: string;
  strategy: "auto" | "manual";
  matched_at: string;
  prior_claim_state?: ClaimState | null;
  is_reversal: boolean;
}

export interface UnmatchedClaim {
  id: string;
  patientControlNumber: string;
  serviceDate: string | null;
  chargeAmount: number;
  providerNpi: string | null;
  payerId: string | null;
  state: ClaimState;
}

export interface UnmatchedRemittance {
  id: string;
  payerClaimControlNumber: string;
  statusCode: string;
  totalCharge: number;
  totalPaid: number;
  adjustmentAmount: number;
  serviceDate: string | null;
  isReversal: boolean;
  batchId: string;
}

export interface UnmatchedResponse {
  claims: UnmatchedClaim[];
  remittances: UnmatchedRemittance[];
}

export interface MatchResponse {
  claim: UnmatchedClaim;
  match: { id: number; strategy: "auto" | "manual" };
}
  • Step 2: Verify typecheck passes

Run: npm run typecheck Expected: clean (no errors).

  • Step 3: Commit
git add src/types/index.ts
git commit -m "feat(frontend): add ClaimState enum + Match/Unmatched types"

Task 19: Add api.listUnmatched, api.matchRemit, api.unmatchClaim to src/lib/api.ts

Files:

  • Modify: src/lib/api.ts

  • Step 1: Read current src/lib/api.ts to see the existing pattern

Look at how listClaims etc. are implemented. Match the style.

  • Step 2: Add the 3 new methods

After the existing listActivity method, add:

async listUnmatched(): Promise<UnmatchedResponse> {
  return this.request<UnmatchedResponse>("/api/reconciliation/unmatched");
},

async matchRemit(claimId: string, remitId: string): Promise<MatchResponse> {
  const r = await fetch(`${this.baseUrl}/api/reconciliation/match`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
  });
  if (!r.ok) throw new ApiError(r.status, await r.text());
  return r.json();
},

async unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
  const r = await fetch(`${this.baseUrl}/api/reconciliation/unmatch`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ claim_id: claimId }),
  });
  if (!r.ok) throw new ApiError(r.status, await r.text());
  return r.json();
},

Add ApiError class if it doesn't exist:

export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

Update the imports at the top:

import type { ..., UnmatchedResponse, MatchResponse } from "@/types";
  • Step 3: Verify typecheck passes

Run: npm run typecheck Expected: clean.

  • Step 4: Commit
git add src/lib/api.ts
git commit -m "feat(frontend): add listUnmatched, matchRemit, unmatchClaim to api"

Task 20: Add useReconciliation hook + tests

Files:

  • Create: src/hooks/useReconciliation.ts

  • Create: src/hooks/useReconciliation.test.ts

  • Step 1: Implement the hook

src/hooks/useReconciliation.ts:

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";

export function useReconciliation() {
  const qc = useQueryClient();

  const unmatched = useQuery({
    queryKey: ["reconciliation", "unmatched"],
    queryFn: () => api.listUnmatched(),
    refetchInterval: 30_000,
  });

  const match = useMutation({
    mutationFn: (args: { claimId: string; remitId: string }) =>
      api.matchRemit(args.claimId, args.remitId),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["reconciliation"] });
      qc.invalidateQueries({ queryKey: ["claims"] });
      qc.invalidateQueries({ queryKey: ["remittances"] });
      qc.invalidateQueries({ queryKey: ["activity"] });
    },
  });

  const unmatch = useMutation({
    mutationFn: (args: { claimId: string }) => api.unmatchClaim(args.claimId),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["reconciliation"] });
      qc.invalidateQueries({ queryKey: ["claims"] });
      qc.invalidateQueries({ queryKey: ["remittances"] });
      qc.invalidateQueries({ queryKey: ["activity"] });
    },
  });

  return { unmatched, match, unmatch };
}
  • Step 2: Write the tests

src/hooks/useReconciliation.test.ts:

import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useReconciliation } from "./useReconciliation";
import { api } from "@/lib/api";

vi.mock("@/lib/api", () => ({
  api: {
    listUnmatched: vi.fn(),
    matchRemit: vi.fn(),
    unmatchClaim: vi.fn(),
  },
}));

function wrapper() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return ({ children }: { children: React.ReactNode }) =>
    React.createElement(QueryClientProvider, { client: qc }, children);
}

describe("useReconciliation", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("returns unmatched query + match + unmatch mutations", async () => {
    (api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
    const { result } = renderHook(() => useReconciliation(), { wrapper: wrapper() });

    await waitFor(() => expect(result.current.unmatched.isSuccess).toBe(true));
    expect(result.current.unmatched.data).toEqual({ claims: [], remittances: [] });
    expect(typeof result.current.match.mutate).toBe("function");
    expect(typeof result.current.unmatch.mutate).toBe("function");
  });

  it("match.onSuccess invalidates reconciliation + claims + remittances + activity", async () => {
    (api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
    (api.matchRemit as any).mockResolvedValue({
      claim: { id: "c1" }, match: { id: 1, strategy: "manual" },
    });
    const { result } = renderHook(() => useReconciliation(), { wrapper: wrapper() });

    result.current.match.mutate({ claimId: "c1", remitId: "r1" });

    await waitFor(() => expect(result.current.match.isSuccess).toBe(true));
  });
});
  • Step 3: Run tests

Run: npm test -- useReconciliation Expected: 2 tests pass.

  • Step 4: Commit
git add src/hooks/useReconciliation.ts src/hooks/useReconciliation.test.ts
git commit -m "feat(frontend): useReconciliation hook with react-query invalidation cascade"

Task 21: claim-state-badge primitive (7-state, same palette)

Files:

  • Create: src/components/ui/claim-state-badge.tsx

  • Step 1: Implement

src/components/ui/claim-state-badge.tsx:

import { Badge } from "@/components/ui/badge";
import type { ClaimState } from "@/types";
import { cn } from "@/lib/utils";
import {
  CircleDashed, Send, Inbox, CheckCircle2, AlertTriangle,
  Ban, CheckCheck, RotateCcw,
} from "lucide-react";

const STATE_CONFIG: Record<ClaimState, {
  label: string;
  icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
  className: string;
}> = {
  submitted:  { label: "Submitted",  icon: Send,        className: "bg-accent/15 text-accent border-accent/30" },
  received:   { label: "Received",   icon: Inbox,       className: "bg-accent/10 text-accent/80 border-accent/20" },
  paid:       { label: "Paid",       icon: CheckCircle2,className: "bg-success/15 text-success border-success/30" },
  partial:    { label: "Partial",    icon: AlertTriangle,className: "bg-warning/15 text-warning border-warning/30" },
  denied:     { label: "Denied",     icon: Ban,         className: "bg-destructive/15 text-destructive border-destructive/30" },
  reconciled: { label: "Reconciled", icon: CheckCheck,  className: "bg-success/15 text-success border-success/30" },
  reversed:   { label: "Reversed",   icon: RotateCcw,   className: "bg-warning/15 text-warning border-warning/30" },
};

export function ClaimStateBadge({ state, className }: { state: ClaimState; className?: string }) {
  const cfg = STATE_CONFIG[state];
  const Icon = cfg.icon;
  return (
    <Badge
      variant="outline"
      className={cn("gap-1.5 font-mono uppercase tracking-wider text-[10px]",
                     cfg.className, className)}
    >
      <Icon className="h-3 w-3" strokeWidth={1.75} />
      {cfg.label}
    </Badge>
  );
}

Note: bg-success/15 and text-success assume these tokens exist in your Tailwind config from sub-project 1. If they don't, use bg-emerald-500/15 and text-emerald-400 (or whatever the existing palette tokens are). Check src/index.css or tailwind.config.js for the available tokens.

  • Step 2: Verify typecheck + build

Run: npm run typecheck && npm run build Expected: clean.

  • Step 3: Commit
git add src/components/ui/claim-state-badge.tsx
git commit -m "feat(frontend): ClaimStateBadge primitive (7 states, same palette)"

Task 22: Refactor StatusBadge.tsx to delegate to ClaimStateBadge + update Claims page

Files:

  • Modify: src/components/StatusBadge.tsx

  • Modify: src/pages/Claims.tsx

  • Step 1: Read the current StatusBadge.tsx to see how it maps the legacy status field

The existing component takes a status string ("submitted", "denied", etc.) and renders a colored badge. Replace the body to delegate to ClaimStateBadge when the value is a known ClaimState.

Modify src/components/StatusBadge.tsx:

import { ClaimStateBadge } from "@/components/ui/claim-state-badge";
import { Badge } from "@/components/ui/badge";
import { CLAIM_STATES, type ClaimState } from "@/types";
import { cn } from "@/lib/utils";

const LEGACY_COLOR: Record<string, string> = {
  draft: "bg-muted text-muted-foreground border-border",
  pending: "bg-warning/15 text-warning border-warning/30",
  accepted: "bg-success/15 text-success border-success/30",
  posted: "bg-warning/15 text-warning border-warning/30",
};

export function StatusBadge({ status, className }: { status: string; className?: string }) {
  if (CLAIM_STATES.includes(status as ClaimState)) {
    return <ClaimStateBadge state={status as ClaimState} className={className} />;
  }
  // Legacy / unmapped status (e.g., "draft" from old data).
  return (
    <Badge variant="outline" className={cn("uppercase tracking-wider text-[10px]",
                                          LEGACY_COLOR[status] ?? "bg-muted text-muted-foreground border-border",
                                          className)}>
      {status}
    </Badge>
  );
}
  • Step 2: Update Claims.tsx to use state from the API

In src/pages/Claims.tsx, find where <StatusBadge> is rendered. Pass claim.state directly (instead of inferring a status string). Confirm the hook returns state as one of the 7 values.

  • Step 3: Verify typecheck + build

Run: npm run typecheck && npm run build Expected: clean.

  • Step 4: Commit
git add src/components/StatusBadge.tsx src/pages/Claims.tsx
git commit -m "refactor(frontend): StatusBadge delegates to ClaimStateBadge; Claims uses API state"

Task 23: Update Remittances.tsx to render real adjustmentAmount

Files:

  • Modify: src/pages/Remittances.tsx

  • Step 1: Read the current page and find where amounts are rendered

  • Step 2: Add an "Adjustment" column if not present

Find the columns array / table header. Add an Adjustment column that renders remit.adjustmentAmount formatted with the existing formatCurrency helper.

  • Step 3: Verify build

Run: npm run build Expected: clean.

  • Step 4: Commit
git add src/pages/Remittances.tsx
git commit -m "feat(frontend): render real adjustmentAmount on Remittances page"

Task 24: Reconciliation page

Files:

  • Create: src/pages/Reconciliation.tsx

  • Create: src/pages/Reconciliation.test.tsx

  • Modify: src/App.tsx (add route)

  • Modify: src/lib/api.test.ts (mock if needed)

  • Step 1: Implement the page

src/pages/Reconciliation.tsx:

import { useState } from "react";
import { useReconciliation } from "@/hooks/useReconciliation";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Button } from "@/components/ui/button";
import { useToast } from "sonner";
import { ApiError } from "@/lib/api";
import { CircleDashed, GitMerge } from "lucide-react";
import { cn } from "@/lib/utils";

export function ReconciliationPage() {
  const { unmatched, match, unmatch } = useReconciliation();
  const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
  const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
  const toast = useToast();

  if (unmatched.isLoading) {
    return (
      <div className="grid grid-cols-2 gap-4">
        <Skeleton variant="card" /><Skeleton variant="card" />
      </div>
    );
  }
  if (unmatched.isError) {
    return <ErrorState error={unmatched.error} onRetry={() => unmatched.refetch()} />;
  }

  const { claims, remittances } = unmatched.data ?? { claims: [], remittances: [] };
  const empty = claims.length === 0 && remittances.length === 0;

  if (empty) {
    return (
      <EmptyState
        eyebrow="Reconciliation · nothing pending"
        message="Every claim and remittance is paired."
      />
    );
  }

  const handleMatch = async () => {
    if (!selectedClaim || !selectedRemit) return;
    try {
      await match.mutateAsync({ claimId: selectedClaim, remitId: selectedRemit });
      toast.success(`Matched ${selectedClaim}${selectedRemit}`);
      setSelectedClaim(null);
      setSelectedRemit(null);
    } catch (e) {
      const msg = e instanceof ApiError && e.status === 409
        ? "Already matched — view the existing match."
        : (e as Error).message;
      toast.error(msg);
    }
  };

  return (
    <div className="space-y-4">
      <div className="grid grid-cols-2 gap-4">
        {/* Claims column */}
        <div className="surface rounded-xl border border-border/60 p-4 space-y-2">
          <h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
            Unmatched claims ({claims.length})
          </h2>
          {claims.map((c) => (
            <button
              key={c.id}
              onClick={() => setSelectedClaim(c.id)}
              className={cn(
                "w-full text-left p-3 rounded-md border transition-colors",
                selectedClaim === c.id
                  ? "border-accent bg-accent/5"
                  : "border-border/60 hover:border-border",
              )}
            >
              <div className="font-mono text-sm">{c.patientControlNumber}</div>
              <div className="text-xs text-muted-foreground">
                {c.serviceDate ?? "—"} · ${c.chargeAmount.toFixed(2)} · NPI {c.providerNpi ?? "—"}
              </div>
            </button>
          ))}
        </div>

        {/* Remits column */}
        <div className="surface rounded-xl border border-border/60 p-4 space-y-2">
          <h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
            Unmatched remits ({remittances.length})
          </h2>
          {remittances.map((r) => (
            <button
              key={r.id}
              onClick={() => setSelectedRemit(r.id)}
              className={cn(
                "w-full text-left p-3 rounded-md border transition-colors",
                selectedRemit === r.id
                  ? "border-accent bg-accent/5"
                  : "border-border/60 hover:border-border",
              )}
            >
              <div className="font-mono text-sm flex items-center gap-2">
                {r.payerClaimControlNumber}
                {r.isReversal && (
                  <span className="text-[10px] text-warning uppercase tracking-wider">
                    Reversal
                  </span>
                )}
              </div>
              <div className="text-xs text-muted-foreground">
                Status {r.statusCode} · ${r.totalPaid.toFixed(2)} paid · ${r.adjustmentAmount.toFixed(2)} adj
              </div>
            </button>
          ))}
        </div>
      </div>

      <div className="flex gap-2">
        <Button
          onClick={handleMatch}
          disabled={!selectedClaim || !selectedRemit || match.isPending}
        >
          <GitMerge className="h-3.5 w-3.5 mr-1.5" />
          Match selected
        </Button>
        <Button
          variant="outline"
          onClick={() => { setSelectedClaim(null); setSelectedRemit(null); }}
        >
          Clear selection
        </Button>
      </div>
    </div>
  );
}

Note: useToast may already be imported from sonner in the existing codebase as toast. Adjust import per project conventions. Check src/components/ui/toast.tsx or existing pages.

  • Step 2: Add the route in src/App.tsx
import { ReconciliationPage } from "@/pages/Reconciliation";
// inside <Routes>:
<Route path="/reconciliation" element={<ReconciliationPage />} />
  • Step 3: Write the test

src/pages/Reconciliation.test.tsx:

import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { ReconciliationPage } from "./Reconciliation";
import { api } from "@/lib/api";

vi.mock("@/lib/api", () => ({
  api: {
    listUnmatched: vi.fn(),
    matchRemit: vi.fn(),
    unmatchClaim: vi.fn(),
  },
  ApiError: class ApiError extends Error { constructor(public status: number, m: string) { super(m); } },
}));

function wrapper() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return ({ children }: { children: React.ReactNode }) =>
    React.createElement(QueryClientProvider, { client: qc }, children);
}

describe("ReconciliationPage", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("renders both columns with unmatched rows", async () => {
    (api.listUnmatched as any).mockResolvedValue({
      claims: [{
        id: "CLM-1", patientControlNumber: "PCN-A", serviceDate: "2026-06-01",
        chargeAmount: 100, providerNpi: "1234567890", payerId: "P1", state: "submitted",
      }],
      remittances: [{
        id: "CLP-1:b1", payerClaimControlNumber: "PCN-A", statusCode: "1",
        totalCharge: 100, totalPaid: 100, adjustmentAmount: 0,
        serviceDate: "2026-06-01", isReversal: false, batchId: "b1",
      }],
    });
    render(<ReconciliationPage />, { wrapper: wrapper() });
    await waitFor(() => expect(screen.getByText("CLM-1")).toBeInTheDocument());
    expect(screen.getByText("PCN-A")).toBeInTheDocument();
  });

  it("renders empty state when nothing is unmatched", async () => {
    (api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
    render(<ReconciliationPage />, { wrapper: wrapper() });
    await waitFor(() =>
      expect(screen.getByText(/nothing pending/i)).toBeInTheDocument()
    );
  });
});
  • Step 4: Verify typecheck + tests pass

Run: npm run typecheck && npm test -- Reconciliation Expected: clean + 2 tests pass.

  • Step 5: Commit
git add src/pages/Reconciliation.tsx src/pages/Reconciliation.test.tsx src/App.tsx
git commit -m "feat(frontend): /reconciliation page with two-column matching UI"

Task 25: Sidebar nav entry with badge count

Files:

  • Modify: src/components/Sidebar.tsx

  • Step 1: Read the current Sidebar.tsx to see the existing NavItem pattern

  • Step 2: Add the Reconciliation nav entry

After the existing <NavItem to="/claims"> (or wherever appropriate), add:

import { GitMerge } from "lucide-react";
import { useReconciliation } from "@/hooks/useReconciliation";

// Inside Sidebar component:
const { unmatched } = useReconciliation();
const unmatchedCount = (unmatched.data?.claims.length ?? 0) +
                       (unmatched.data?.remittances.length ?? 0);

// In the nav list:
<NavItem to="/reconciliation" icon={<GitMerge className="h-4 w-4" strokeWidth={1.5} />}>
  Reconciliation
  {unmatchedCount > 0 && (
    <span className="ml-auto text-[10px] font-mono tabular-nums text-warning">
      {unmatchedCount > 99 ? "99+" : unmatchedCount}
    </span>
  )}
</NavItem>
  • Step 3: Verify build

Run: npm run build Expected: clean.

  • Step 4: Commit
git add src/components/Sidebar.tsx
git commit -m "feat(frontend): sidebar Reconciliation nav entry with badge count"

Task 26: Vitest config — exclude .worktrees/

Files:

  • Modify: vitest.config.ts

  • Step 1: Add the exclude

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    exclude: [
      "**/node_modules/**",
      "**/dist/**",
      ".worktrees/**",   // ← NEW
    ],
  },
});
  • Step 2: Run tests; verify count is 7 (not 10+)

Run: npm test Expected: Test Files 1 passed (1) and Tests 7 passed (7). The worktree's tests no longer get picked up.

  • Step 3: Commit
git add vitest.config.ts
git commit -m "chore(frontend): exclude .worktrees from vitest discovery"

Phase 7 — Docs + housekeeping + smoke

Task 27: Update root README — DB path, CYCLONE_DB_URL, backup recipe

Files:

  • Modify: README.md

  • Step 1: Read current README to see existing structure

The README was rewritten in sub-project 1. Find the "Install" and "Dev" sections.

  • Step 2: Add a "Persistence" subsection

After the "Test" section (or as a new top-level section), add:

## Persistence

Parsed batches, claims, remittances, matches, and activity events are
stored in a SQLite file at `~/.local/share/cyclone/cyclone.db` by
default. The directory is auto-created on first run.

To use a different location, set `CYCLONE_DB_URL`:

```bash
export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db
# or
export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone

Backup

sqlite3 ~/.local/share/cyclone/cyclone.db ".backup /path/to/backup.db"

This is safe to run while the backend is running (uses SQLite's online backup API).


- [ ] **Step 3: Update the Roadmap section to reflect sub-project 2 completion**

```markdown
## Roadmap

Sub-project 2 (DB + reconciliation) is **shipped**. The other two:

- **Sub-project 3 — More 837P/835 features.** Additional 837P validation
  rules (REF*G1 enforcement, BHT06), 835 CAS deep-parsing, 999 ACK, and
  270/271 eligibility.
- **Sub-project 4 — Frontend features.** Per-claim detail drawer, batch
  diff view, real-time streaming of NDJSON GET responses, advanced
  filters (date range, multi-status, saved filter sets), keyboard-driven
  navigation.
  • Step 4: Commit
git add README.md
git commit -m "docs: document CYCLONE_DB_URL, backup recipe, sub-project 2 completion"

Task 28: Tick off the 107 unchecked boxes in the production-readiness plan

Files:

  • Modify: docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md

  • Step 1: Confirm the housekeeping commit is appropriate

All sub-project 1 work is merged to main; all the boxes in that plan correspond to work that landed in real commits. Ticking them off is a cosmetic update for future archaeology.

  • Step 2: Bulk-replace the checkboxes

Run:

sed -i '' 's/- \[ \]/- [x]/g' docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md

Verify: grep -c "\- \[x\]" docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md Expected: ~107 matches (was 0).

  • Step 3: Commit
git add docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md
git commit -m "docs: tick off completed boxes in sub-project 1 plan (housekeeping)"

Task 29: Smoke test — full end-to-end

Files: none (manual procedure; recorded in the commit message + acceptance checklist)

  • Step 1: Clean DB
rm -f ~/.local/share/cyclone/cyclone.db
  • Step 2: Start backend
cd backend && .venv/bin/python -m cyclone serve

In another terminal, verify the DB was created:

sqlite3 ~/.local/share/cyclone/cyclone.db ".tables"

Expected: activity_events batches cas_adjustments claims matches remittances.

  • Step 3: Start frontend
cd .. && npm run dev
  • Step 4: Upload CO 837P

Open http://localhost:5173/upload. Drop backend/tests/fixtures/co_medicaid_837p.txt.

Expected:

  • Toast "Parsed 2 claims".

  • Claims page shows 2 rows with state submitted.

  • Step 5: Upload CO 835

Drop backend/tests/fixtures/co_medicaid_835.txt.

Expected:

  • Toast includes "matched: N".

  • Claims page: matched rows show paid or reconciled (depending on fixture content).

  • Any orphan remits appear in /reconciliation page.

  • Step 6: Visit /reconciliation

Verify:

  • Unmatched claims column populates.

  • Unmatched remits column populates.

  • Click a claim → it highlights.

  • Click a remit → it highlights.

  • Click "Match selected" → both rows disappear; toast "Matched X ↔ Y".

  • Step 7: Restart persistence test

# Ctrl-C the backend
cd backend && .venv/bin/python -m cyclone serve &

Reload the frontend. All data still present.

  • Step 8: Full test suite
cd backend && .venv/bin/pytest -q

Expected: ~213 tests pass.

npm run typecheck && npm run build && npm test

Expected: clean, clean, 7 tests pass (no .worktrees/ duplicates).

  • Step 9: Manual commit (no code changes) — record the smoke pass
git commit --allow-empty -m "smoke: end-to-end DB + reconciliation flow passes (sub-project 2)"

Task 30: Final cleanup — merge worktree to main

This task runs after smoke. It's the merge step, not a coding task.

  • Step 1: Verify the worktree is clean
cd .worktrees/db-reconciliation && git status

Expected: nothing to commit, on branch db-reconciliation.

  • Step 2: Use the finishing-a-development-branch skill

Invoke the skill. It will guide you through merge options.

The likely outcome: fast-forward merge db-reconciliation into main on the local repo (no PR — local-only tool).

  • Step 3: Clean up the worktree

After merge, the skill will offer to remove .worktrees/db-reconciliation/. Accept.

  • Step 4: Verify main is at the expected state
git log --oneline | head -10

Expected: ~40 new commits ahead of cf9af8e (the spec commit), all clean.


Self-review (against the spec)

After writing the plan, walk through each spec section and confirm coverage:

Spec § Coverage
1. Overview n/a (narrative)
2. Goals T1 (SQLAlchemy dep), T9 (store facade), T10 (reconcile.run), T22 (status badge), T23 (adjustmentAmount column), T3 (migrations)
3. Non-goals respected (no CAS reason-code explanations, no multi-user, no auth)
4. Stack T1 (SQLAlchemy dep), T18-T26 (frontend additions with no new npm deps)
5. Architecture T9 (CycloneStore as facade), T7-T8 (reconcile pure fns), T14-T15 (API endpoints)
6. Data model T3 (SQL migration), T4-T6 (6 ORM models)
7. Reconciliation T7 (match), T8 (apply_payment / apply_reversal / split_unmatched), T10 (run orchestrator)
8. Migration runner T3 (cyclone.db_migrate + 0001_initial)
9. Store facade T9 (CycloneStore class with all public methods)
10. API additions T14, T15, T16 (3 new endpoints + parse-835 summary)
11. Frontend additions T18 (types), T19 (api.ts), T20 (useReconciliation), T21 (ClaimStateBadge), T24 (page), T25 (sidebar)
12. Data flow covered via integration tests in T10, T11, T12, T16
13. Error handling T10 (reconcile_failed path), T14-T15 (HTTP error codes), T8 (skipped intents)
14. Testing ~32 backend (T2+T3+T4+T5+T6+T7+T8+T10+T11+T12+T14+T15+T16) + 4 frontend (T20+T24)
15. Migration / rollout T13 (db.init() in startup), T27 (README docs)
16. Out of scope respected (no Alembic, no field rename, no multi-user)
17. Acceptance T29 (smoke), T17 (TODO marker removed)

Placeholder scan: no TBD, TODO, TBA, "implement later", "fill in details", "add appropriate error handling", "similar to Task N" without context, "write tests" without code.

Type consistency: ClaimState is str-enum in Python (used as "submitted" etc. via .value) and ClaimState is a TS union type in src/types/index.ts. The mapper to_ui_claim writes c.state.value to the API response; the frontend reads claim.state as a string and passes it to <ClaimStateBadge state={...}>. Manual_match raises AlreadyMatchedError / InvalidStateError; the API converts them to 409. ApplyIntent is consistent across apply_payment, apply_reversal, and reconcile.run. Match (reconcile result) vs Match (ORM model) — reconciled by name Match in reconcile.py is the dataclass; the ORM model in db.py is Match. There is a name collision that the tests guard against by importing from explicit modules. No ambiguity at runtime because reconcile.match() returns the dataclass and db.Match is only used inside SQLAlchemy sessions.

No spec gaps found. All 17 spec sections are covered.


Execution

After saving this plan, choose an execution mode:

  1. Subagent-Driven (recommended) — fresh subagent per task, two-stage review between tasks, fast iteration. Requires superpowers:subagent-driven-development.
  2. Inline Execution — execute tasks in this session using superpowers:executing-plans, batch execution with checkpoints.