diff --git a/docs/superpowers/plans/2026-06-19-cyclone-db-reconciliation.md b/docs/superpowers/plans/2026-06-19-cyclone-db-reconciliation.md new file mode 100644 index 0000000..c8d993c --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-cyclone-db-reconciliation.md @@ -0,0 +1,4313 @@ +# 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: + +```toml + "sqlalchemy>=2.0,<3", +``` + +The block should look like: +```toml +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** + +```bash +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`: +```python +"""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`: +```python +"""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: +> ```python +> 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** + +```bash +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`: +```python +"""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`: +```python +"""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`: +```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** + +```bash +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`: +```python +"""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`: + +```python +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** + +```bash +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`: + +```python +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`: + +```python +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** + +```bash +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`: + +```python +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`: + +```python +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** + +```bash +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`: +```python +"""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`: +```python +"""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** + +```bash +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`: + +```python +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`: + +```python +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** + +```bash +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`: + +```python +"""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`: + +```python +"""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** + +```bash +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`: + +```python +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`: + +```python +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** + +```bash +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`: + +```python +"""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: + +```python + 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: + +```python +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** + +```bash +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`: + +```python +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: +> +> ```python +> 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** + +```bash +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** + +```python +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** + +```bash +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`: + +```python +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: + +```python +from cyclone.store import AlreadyMatchedError, InvalidStateError +``` + +Add a new route: + +```python +@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** + +```bash +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`: + +```python +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`: + +```python +@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** + +```bash +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`: + +```python +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: + +```python +@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** + +```bash +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: + +```ts +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** + +```bash +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: + +```ts +async listUnmatched(): Promise { + return this.request("/api/reconciliation/unmatched"); +}, + +async matchRemit(claimId: string, remitId: string): Promise { + 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: + +```ts +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} +``` + +Update the imports at the top: + +```ts +import type { ..., UnmatchedResponse, MatchResponse } from "@/types"; +``` + +- [ ] **Step 3: Verify typecheck passes** + +Run: `npm run typecheck` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +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`: + +```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`: + +```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** + +```bash +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`: + +```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; + 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 ( + + + {cfg.label} + + ); +} +``` + +> **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** + +```bash +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`: + +```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 = { + 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 ; + } + // Legacy / unmapped status (e.g., "draft" from old data). + return ( + + {status} + + ); +} +``` + +- [ ] **Step 2: Update `Claims.tsx` to use `state` from the API** + +In `src/pages/Claims.tsx`, find where `` 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** + +```bash +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** + +```bash +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`: + +```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(null); + const [selectedRemit, setSelectedRemit] = useState(null); + const toast = useToast(); + + if (unmatched.isLoading) { + return ( +
+ +
+ ); + } + if (unmatched.isError) { + return unmatched.refetch()} />; + } + + const { claims, remittances } = unmatched.data ?? { claims: [], remittances: [] }; + const empty = claims.length === 0 && remittances.length === 0; + + if (empty) { + return ( + + ); + } + + 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 ( +
+
+ {/* Claims column */} +
+

+ Unmatched claims ({claims.length}) +

+ {claims.map((c) => ( + + ))} +
+ + {/* Remits column */} +
+

+ Unmatched remits ({remittances.length}) +

+ {remittances.map((r) => ( + + ))} +
+
+ +
+ + +
+
+ ); +} +``` + +> **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`** + +```tsx +import { ReconciliationPage } from "@/pages/Reconciliation"; +// inside : +} /> +``` + +- [ ] **Step 3: Write the test** + +`src/pages/Reconciliation.test.tsx`: + +```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(, { 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(, { 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** + +```bash +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 `` (or wherever appropriate), add: + +```tsx +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: +}> + Reconciliation + {unmatchedCount > 0 && ( + + {unmatchedCount > 99 ? "99+" : unmatchedCount} + + )} + +``` + +- [ ] **Step 3: Verify build** + +Run: `npm run build` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +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** + +```ts +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** + +```bash +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: + +```markdown +## 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 + +```bash +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** + +```bash +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: +```bash +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** + +```bash +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** + +```bash +rm -f ~/.local/share/cyclone/cyclone.db +``` + +- [ ] **Step 2: Start backend** + +```bash +cd backend && .venv/bin/python -m cyclone serve +``` + +In another terminal, verify the DB was created: +```bash +sqlite3 ~/.local/share/cyclone/cyclone.db ".tables" +``` + +Expected: `activity_events batches cas_adjustments claims matches remittances`. + +- [ ] **Step 3: Start frontend** + +```bash +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** + +```bash +# Ctrl-C the backend +cd backend && .venv/bin/python -m cyclone serve & +``` + +Reload the frontend. All data still present. + +- [ ] **Step 8: Full test suite** + +```bash +cd backend && .venv/bin/pytest -q +``` +Expected: ~213 tests pass. + +```bash +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** + +```bash +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** + +```bash +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** + +```bash +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 ``. `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. + + +--- + + + +--- + + + +--- +