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