Implements the spec at docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md. Phase 1 — Backend foundation (T1-T3): SQLAlchemy dep, db.py skeleton, PRAGMA user_version migration runner, 0001_initial.sql with 6 tables. Phase 2 — ORM models (T4-T6): Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent + ClaimState enum. Phase 3 — Reconciliation pure functions (T7-T8): match() with date window + multi-claim fallback, apply_payment/apply_reversal, split_unmatched. Phase 4 — Store facade refactor (T9-T12): CycloneStore replaces InMemoryStore with same public API + list_unmatched / manual_match / manual_unmatch; reconcile.run orchestrator wired into store.add(). Phase 5 — API additions (T13-T17): db.init() in startup, GET /api/reconciliation/unmatched, POST /match, POST /unmatch, parse-835 response includes reconciliation summary; TODO marker removal verification. Phase 6 — Frontend (T18-T26): ClaimState types, api.listUnmatched/ matchRemit/unmatchClaim, useReconciliation hook, ClaimStateBadge primitive (7 states, same palette), Reconciliation page, sidebar nav entry, vitest worktree exclude. Phase 7 — Docs + smoke (T27-T30): README persistence section + DB URL docs, tick off sub-project 1 plan boxes, full end-to-end smoke procedure, merge to main. Total: 30 tasks across 7 phases. TDD discipline per task with ~32 new backend tests + ~4 new frontend tests. Target after completion: 210 backend + 7 frontend tests passing.
149 KiB
Cyclone DB + Reconciliation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the in-memory InMemoryStore with SQLite persistence via SQLAlchemy 2.0; add automatic 837P ↔ 835 claim reconciliation on every successful 835 parse; ship a manual /reconciliation page for unmatched claims and remittances; extend the claim lifecycle to a 7-state model with reversal handling. Persist data across backend restarts and remove the adjustmentAmount: 0.0 TODO marker from store.py:376.
Architecture: Three new modules — cyclone.db (SQLAlchemy 2.0 engine, SessionLocal, declarative Base, 6 ORM models), cyclone.db_migrate (~40 LOC PRAGMA user_version runner), cyclone.reconcile (pure functions: match, apply_payment, apply_reversal, split_unmatched). The existing cyclone.store becomes a thin facade over SQLAlchemy, preserving its public API so api.py and the mappers change minimally. Reconciliation is triggered inside store.add() after the ERA is persisted; failures are fail-soft. One new page (/reconciliation), one new hook (useReconciliation), one new primitive (claim-state-badge). SQLite at ~/.local/share/cyclone/cyclone.db, overridable via CYCLONE_DB_URL.
Tech Stack: Python 3.11+, FastAPI (existing), SQLAlchemy 2.0 (NEW), pytest (existing), Pydantic v2 (existing). React 18 + Vite + TypeScript + Tailwind + @tanstack/react-query v5 (existing), lucide-react, sonner, zustand. No new npm deps.
Spec: docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md (approved at commit cf9af8e)
Worktree: Use superpowers:using-git-worktrees to create .worktrees/db-reconciliation from main before Task 1.
File map
New backend files
| File | Responsibility |
|---|---|
backend/src/cyclone/db.py |
init_db(), SessionLocal(), Base, 6 ORM model classes, ClaimState enum |
backend/src/cyclone/db_migrate.py |
run(engine) — PRAGMA user_version runner (~40 LOC) |
backend/src/cyclone/reconcile.py |
Pure functions: match, apply_payment, apply_reversal, split_unmatched, run (orchestrator) |
backend/src/cyclone/migrations/0001_initial.sql |
DDL for all 6 tables |
backend/tests/test_reconcile.py |
~12 pure-function tests |
backend/tests/test_db_migrate.py |
~3 migration tests |
backend/tests/test_store_reconcile.py |
~10 integration tests (in-memory SQLite) |
Modified backend files
| File | Change |
|---|---|
backend/pyproject.toml |
Add sqlalchemy>=2.0,<3 |
backend/src/cyclone/store.py |
Rewrite internals from list[BatchRecord] to SQLAlchemy; preserve public API; add list_unmatched, manual_match, manual_unmatch |
backend/src/cyclone/api.py |
Add GET /api/reconciliation/unmatched, POST /api/reconciliation/match, POST /api/reconciliation/unmatch; update parse-835 response |
backend/src/cyclone/__main__.py |
Call db.init() before uvicorn starts |
backend/tests/test_api_gets.py |
Extend with 3 new endpoint tests |
backend/tests/test_api_parse_persists.py |
Extend with reconcile-trigger test + fault-injection test |
New frontend files
| File | Responsibility |
|---|---|
src/hooks/useReconciliation.ts |
useReconciliation() — query + match + unmatch mutations |
src/hooks/useReconciliation.test.ts |
~2 tests |
src/components/ui/claim-state-badge.tsx |
7-state badge reusing existing palette |
src/pages/Reconciliation.tsx |
Two-column unmatched view + Match button |
src/pages/Reconciliation.test.tsx |
~2 tests |
Modified frontend files
| File | Change |
|---|---|
src/types/index.ts |
Add ClaimState enum (7 values), Match, UnmatchedClaim, UnmatchedRemittance |
src/lib/api.ts |
Add listUnmatched, matchRemit, unmatchClaim |
src/components/StatusBadge.tsx |
Delegate 7-state subset to new primitive |
src/components/Sidebar.tsx |
Add Reconciliation nav item with badge count |
src/pages/Claims.tsx |
Render new 7-state badge; accept claim.state directly |
src/pages/Remittances.tsx |
Render adjustmentAmount from API |
vitest.config.ts |
Exclude .worktrees/ from test discovery |
Modified docs files
| File | Change |
|---|---|
README.md |
Document CYCLONE_DB_URL, default DB path, backup recipe; update roadmap |
docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md |
Tick off the 107 unchecked boxes (housekeeping) |
Phase 1 — Backend foundation
Task 1: Add SQLAlchemy dependency
Files:
-
Modify:
backend/pyproject.toml -
Step 1: Add
sqlalchemyto dependencies
In backend/pyproject.toml, in the dependencies list (under [project]), add:
"sqlalchemy>=2.0,<3",
The block should look like:
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
"pydantic>=2.6,<3",
"sqlalchemy>=2.0,<3",
]
- Step 2: Install the new dep into the venv
Run: cd backend && .venv/bin/pip install -e '.[dev]'
Expected: installs sqlalchemy without error.
- Step 3: Verify import works
Run: cd backend && .venv/bin/python -c "import sqlalchemy; print(sqlalchemy.__version__)"
Expected: prints a version like 2.0.x.
- Step 4: Commit
git add backend/pyproject.toml backend/uv.lock 2>/dev/null || git add backend/pyproject.toml
cd backend && .venv/bin/pip freeze | grep -i sqlalchemy > /tmp/sqla.txt && git add /tmp/sqla.txt 2>/dev/null || true
git commit -m "build(backend): add sqlalchemy>=2.0,<3 dependency"
(If backend/uv.lock doesn't exist, omit it from git add.)
Task 2: cyclone.db skeleton — Base, SessionLocal, engine factory, init_db
Files:
-
Create:
backend/src/cyclone/db.py -
Create:
backend/tests/test_db.py -
Step 1: Write the failing tests
backend/tests/test_db.py:
"""Tests for the SQLAlchemy engine + session factory + init_db."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import sqlalchemy as sa
from cyclone import db
@pytest.fixture(autouse=True)
def _isolated_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Point CYCLONE_DB_URL at a temp file for every test in this module."""
url = f"sqlite:///{tmp_path}/test.db"
monkeypatch.setenv("CYCLONE_DB_URL", url)
# Reset module-level state so each test gets a fresh engine.
db._reset_for_tests()
def test_init_db_creates_engine() -> None:
db.init_db()
assert db.engine is not None
assert isinstance(db.engine, sa.Engine)
def test_init_db_creates_all_tables(tmp_path: Path) -> None:
db.init_db()
# Probe a known future table name from the schema. We use
# `inspect` rather than importing the model class to keep this
# test independent of model implementation.
inspector = sa.inspect(db.engine)
# In Task 3 we add a Claim table; until then this is empty.
assert isinstance(inspector.get_table_names(), list)
def test_session_local_returns_session() -> None:
db.init_db()
with db.SessionLocal() as s:
assert s.is_active
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_db.py -v
Expected: ModuleNotFoundError: No module named 'cyclone.db' or AttributeError on db.engine.
- Step 3: Implement
cyclone.dbskeleton
backend/src/cyclone/db.py:
"""SQLAlchemy 2.0 engine, session factory, and declarative Base.
`init_db()` is called from `cyclone.__main__:serve` before uvicorn starts.
It reads `CYCLONE_DB_URL` (default `sqlite:///~/.local/share/cyclone/cyclone.db`),
creates the engine, runs `Base.metadata.create_all`, then runs the migration
runner. Calling `init_db()` is idempotent — calling it twice on the same URL
re-uses the same engine.
"""
from __future__ import annotations
import os
from pathlib import Path
import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "cyclone" / "cyclone.db"
DEFAULT_DB_URL = f"sqlite:///{DEFAULT_DB_PATH}"
class Base(DeclarativeBase):
"""Declarative base for all ORM models in the project."""
_engine: sa.Engine | None = None
_SessionLocal: sessionmaker[sa.orm.Session] | None = None
def _resolve_url() -> str:
url = os.environ.get("CYCLONE_DB_URL")
if url:
return url
DEFAULT_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
return DEFAULT_DB_URL
def _make_engine(url: str) -> sa.Engine:
"""Build an Engine with sensible defaults for SQLite + FastAPI."""
connect_args: dict[str, object] = {}
if url.startswith("sqlite"):
connect_args = {"check_same_thread": False}
return sa.create_engine(
url,
connect_args=connect_args,
future=True,
)
def init_db() -> None:
"""Create the engine + session factory; run create_all + migrations.
Idempotent. Safe to call multiple times for the same URL.
"""
global _engine, _SessionLocal
if _engine is not None:
return # already initialized
url = _resolve_url()
_engine = _make_engine(url)
# Import here to avoid circular imports with models.
from cyclone import db_migrate # noqa: WPS433 (deliberate late import)
Base.metadata.create_all(_engine)
db_migrate.run(_engine)
_SessionLocal = sessionmaker(
bind=_engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)
def _reset_for_tests() -> None:
"""Clear module-level state. Test-only."""
global _engine, _SessionLocal
if _engine is not None:
_engine.dispose()
_engine = None
_SessionLocal = None
@property
def engine() -> sa.Engine:
if _engine is None:
raise RuntimeError("db.init_db() has not been called")
return _engine
@property
def SessionLocal() -> sessionmaker[sa.orm.Session]:
if _SessionLocal is None:
raise RuntimeError("db.init_db() has not been called")
return _SessionLocal
# Make these accessible as module attributes (`db.engine`, `db.SessionLocal`)
# rather than only as properties, so `db.engine` works in tests and code.
engine = property(lambda self: engine.fget(None)) # type: ignore[assignment]
SessionLocal = property(lambda self: SessionLocal.fget(None)) # type: ignore[assignment]
Note: the two
propertyre-exports at the bottom are a deliberate trick so thatdb.engineanddb.SessionLocalwork both as module attributes and inside the test fixture that needs to reset them. If your linter complains, replace with explicit module-level getters:def engine() -> sa.Engine: ... def SessionLocal() -> sessionmaker: ...and call
db.engine()instead ofdb.engineeverywhere. Pick one style and use it consistently.
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_db.py -v
Expected: 3 tests pass.
- Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db.py
git commit -m "feat(backend): add cyclone.db with engine, SessionLocal, Base, init_db"
Task 3: cyclone.db_migrate runner + initial migration
Files:
-
Create:
backend/src/cyclone/db_migrate.py -
Create:
backend/src/cyclone/migrations/0001_initial.sql -
Create:
backend/tests/test_db_migrate.py -
Step 1: Write the failing tests
backend/tests/test_db_migrate.py:
"""Tests for the PRAGMA user_version migration runner."""
from __future__ import annotations
from pathlib import Path
import pytest
import sqlalchemy as sa
from cyclone import db_migrate
def _fresh_engine(tmp_path: Path) -> sa.Engine:
return sa.create_engine(f"sqlite:///{tmp_path}/m.db", future=True)
def _user_version(engine: sa.Engine) -> int:
with engine.connect() as c:
return c.exec_driver_sql("PRAGMA user_version").scalar() or 0
def test_run_on_empty_db_bumps_to_latest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
(tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n")
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
assert _user_version(engine) == 1
with engine.connect() as c:
rows = c.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table' AND name='t'").all()
assert len(rows) == 1
def test_run_is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
(tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t (id INTEGER);\n")
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
db_migrate.run(engine) # second call should be a no-op
assert _user_version(engine) == 1
def test_run_skips_already_applied_migrations(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
(tmp_path / "0001_initial.sql").write_text("-- version: 1\nCREATE TABLE t1 (id INTEGER);\n")
(tmp_path / "0002_add_col.sql").write_text("-- version: 2\nALTER TABLE t1 ADD COLUMN name TEXT;\n")
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
assert _user_version(engine) == 2
# Modify 0002 (a no-op for SQLite ALTER, but we want to verify the
# runner doesn't re-run already-applied migrations).
db_migrate.run(engine)
assert _user_version(engine) == 2
def test_run_raises_on_missing_version_header(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(db_migrate, "MIGRATIONS_DIR", tmp_path)
(tmp_path / "0001_bad.sql").write_text("CREATE TABLE t (id INTEGER);\n") # no header
engine = _fresh_engine(tmp_path)
with pytest.raises(RuntimeError, match="missing '-- version"):
db_migrate.run(engine)
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_db_migrate.py -v
Expected: ModuleNotFoundError: No module named 'cyclone.db_migrate'.
- Step 3: Implement
cyclone.db_migrate
backend/src/cyclone/db_migrate.py:
"""PRAGMA user_version-based migration runner.
Each migration is a `.sql` file under `backend/src/cyclone/migrations/`
with a leading comment of the form `-- version: N`. The runner reads
the current `PRAGMA user_version`, sorts migrations by filename, and
applies any whose version is greater than the current value. Each
migration runs in its own transaction; a failure rolls back the
version bump so the next startup retries.
This is intentionally NOT Alembic. One operator, one machine, one
schema-version counter.
"""
from __future__ import annotations
import re
from pathlib import Path
import sqlalchemy as sa
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
_VERSION_RE = re.compile(r"^--\s*version:\s*(\d+)", re.MULTILINE)
def _migration_version(sql: str, filename: str) -> int:
m = _VERSION_RE.search(sql)
if not m:
raise RuntimeError(
f"{filename}: missing '-- version: N' header comment"
)
return int(m.group(1))
def _strip_comments(sql: str) -> str:
"""Remove `--` line comments; preserve everything else."""
lines = []
for line in sql.splitlines():
stripped = line.strip()
if stripped.startswith("--"):
continue
lines.append(line)
return "\n".join(lines)
def run(engine: sa.Engine) -> None:
"""Apply any pending migrations. Idempotent."""
migrations_dir = MIGRATIONS_DIR
if not migrations_dir.exists():
return # no migrations directory yet — fresh checkout, no-op
migration_files = sorted(migrations_dir.glob("*.sql"))
if not migration_files:
return
with engine.begin() as conn:
current = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
for path in migration_files:
sql = path.read_text()
version = _migration_version(sql, path.name)
if version <= current:
continue
statements_sql = _strip_comments(sql)
statements = [s.strip() for s in statements_sql.split(";") if s.strip()]
with engine.begin() as conn:
for stmt in statements:
conn.exec_driver_sql(stmt)
conn.exec_driver_sql(f"PRAGMA user_version = {version}")
current = version
- Step 4: Create the initial migration
Create the directory backend/src/cyclone/migrations/ (it may already exist).
backend/src/cyclone/migrations/0001_initial.sql:
-- version: 1
-- Initial schema: 6 tables for Cyclone DB + reconciliation.
CREATE TABLE batches (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
input_filename TEXT NOT NULL,
parsed_at DATETIME NOT NULL,
totals_json TEXT,
validation_json TEXT,
raw_result_json TEXT
);
CREATE INDEX ix_batches_parsed_at ON batches(parsed_at DESC);
CREATE TABLE claims (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
patient_control_number TEXT NOT NULL,
service_date_from DATE,
service_date_to DATE,
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
provider_npi TEXT,
payer_id TEXT,
state TEXT NOT NULL DEFAULT 'submitted',
state_before_reversal TEXT,
matched_remittance_id TEXT REFERENCES remittances(id),
raw_json TEXT,
UNIQUE (batch_id, patient_control_number)
);
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
CREATE TABLE remittances (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
payer_claim_control_number TEXT NOT NULL,
claim_id TEXT REFERENCES claims(id),
status_code TEXT NOT NULL,
status_label TEXT,
total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,
total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,
patient_responsibility NUMERIC(12, 2),
adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
received_at DATETIME NOT NULL,
service_date DATE,
is_reversal INTEGER NOT NULL DEFAULT 0,
raw_json TEXT,
UNIQUE (batch_id, payer_claim_control_number)
);
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
CREATE INDEX ix_remittances_status_code ON remittances(status_code);
CREATE TABLE cas_adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
group_code TEXT NOT NULL,
reason_code TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
quantity NUMERIC(10, 2)
);
CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);
CREATE TABLE matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id TEXT NOT NULL UNIQUE REFERENCES claims(id) ON DELETE CASCADE,
remittance_id TEXT NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
strategy TEXT NOT NULL,
matched_at DATETIME NOT NULL,
prior_claim_state TEXT,
is_reversal INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX ix_matches_remittance_id ON matches(remittance_id);
CREATE INDEX ix_matches_matched_at ON matches(matched_at);
CREATE TABLE activity_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts DATETIME NOT NULL,
kind TEXT NOT NULL,
batch_id TEXT,
claim_id TEXT,
remittance_id TEXT,
payload_json TEXT
);
CREATE INDEX ix_activity_events_ts ON activity_events(ts DESC);
CREATE INDEX ix_activity_events_kind ON activity_events(kind);
- Step 5: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_db_migrate.py -v
Expected: 4 tests pass.
- Step 6: Commit
git add backend/src/cyclone/db_migrate.py backend/src/cyclone/migrations/ backend/tests/test_db_migrate.py
git commit -m "feat(backend): add PRAGMA user_version migration runner + 0001_initial"
Phase 2 — ORM models
Task 4: Batch + ClaimState enum + Claim ORM models
Files:
-
Modify:
backend/src/cyclone/db.py -
Create:
backend/tests/test_db_models.py -
Step 1: Write the failing tests
backend/tests/test_db_models.py:
"""Tests for ORM model definitions and CRUD."""
from __future__ import annotations
import enum
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.db import Base, Batch, Claim, ClaimState
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
def test_claim_state_enum_values():
assert ClaimState.SUBMITTED.value == "submitted"
assert ClaimState.RECEIVED.value == "received"
assert ClaimState.PAID.value == "paid"
assert ClaimState.PARTIAL.value == "partial"
assert ClaimState.DENIED.value == "denied"
assert ClaimState.RECONCILED.value == "reconciled"
assert ClaimState.REVERSED.value == "reversed"
# 7 values total.
assert len(list(ClaimState)) == 7
def test_create_and_query_batch():
with db.SessionLocal() as s:
b = Batch(
id="b1",
kind="837p",
input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
)
s.add(b)
s.commit()
with db.SessionLocal() as s:
loaded = s.get(Batch, "b1")
assert loaded is not None
assert loaded.kind == "837p"
assert loaded.totals_json == {"total_claims": 1}
def test_create_and_query_claim_with_default_state():
with db.SessionLocal() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
c = Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
charge_amount=Decimal("124.00"),
provider_npi="1234567890",
payer_id="SKCO0",
state=ClaimState.SUBMITTED,
)
s.add(c)
s.commit()
with db.SessionLocal() as s:
loaded = s.get(Claim, "CLM-1")
assert loaded is not None
assert loaded.state == ClaimState.SUBMITTED
assert loaded.charge_amount == Decimal("124.00")
assert loaded.service_date_from == date(2026, 6, 1)
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v
Expected: ImportError on cyclone.db.Claim / cyclone.db.ClaimState.
- Step 3: Add
ClaimStateenum +Batch+Claimmodels
Append to backend/src/cyclone/db.py:
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from sqlalchemy import (
Boolean,
Date,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
class ClaimState(str, enum.Enum):
SUBMITTED = "submitted"
RECEIVED = "received"
PAID = "paid"
PARTIAL = "partial"
DENIED = "denied"
RECONCILED = "reconciled"
REVERSED = "reversed"
class Batch(Base):
__tablename__ = "batches"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
kind: Mapped[str] = mapped_column(String(8), nullable=False)
input_filename: Mapped[str] = mapped_column(String(512), nullable=False)
parsed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
totals_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
validation_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
raw_result_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
claims: Mapped[list["Claim"]] = relationship(
back_populates="batch", cascade="all, delete-orphan"
)
remittances: Mapped[list["Remittance"]] = relationship(
back_populates="batch", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_batches_parsed_at", "parsed_at"),
)
class Claim(Base):
__tablename__ = "claims"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
)
patient_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
service_date_from: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
state: Mapped[ClaimState] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
)
state_before_reversal: Mapped[Optional[ClaimState]] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=True
)
matched_remittance_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("remittances.id"), nullable=True
)
raw_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
batch: Mapped["Batch"] = relationship(back_populates="claims")
__table_args__ = (
UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"),
Index("ix_claims_state", "state"),
Index("ix_claims_patient_control_number", "patient_control_number"),
Index("ix_claims_service_date_from", "service_date_from"),
)
Important:
Claim.matched_remittance_idreferencesremittances.id(FK to Remittance). ButRemittanceis defined in Task 5. SQLAlchemy 2.0 resolves forward references via the string"remittances.id"in theForeignKeycall, so this works even thoughRemittancedoesn't exist yet. TherelationshiponBatchalready references"Claim"and"Remittance"as strings — same mechanism.
Migration update: The migration
0001_initial.sqldeclaredclaims.matched_remittance_id REFERENCES remittances(id). SQLAlchemy'screate_all(whichinit_dbcalls) will create the same constraint. But the migration runner is the source of truth for fresh DBs. Thecreate_allcall is a safety net for tests that want to skip migrations. Both produce identical DDL.
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v
Expected: 3 tests pass.
- Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Batch + Claim ORM models + ClaimState enum"
Task 5: Remittance + CasAdjustment ORM models
Files:
-
Modify:
backend/src/cyclone/db.py -
Modify:
backend/tests/test_db_models.py -
Step 1: Add failing tests
Append to backend/tests/test_db_models.py:
from cyclone.db import CasAdjustment, Remittance
def test_create_and_query_remittance():
with db.SessionLocal() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1",
batch_id="b1",
payer_claim_control_number="CLP-1",
status_code="1",
total_charge=Decimal("124.00"),
total_paid=Decimal("124.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
service_date=date(2026, 6, 1),
is_reversal=False,
)
s.add(r)
s.commit()
with db.SessionLocal() as s:
loaded = s.get(Remittance, "CLP-1")
assert loaded is not None
assert loaded.status_code == "1"
assert loaded.total_paid == Decimal("124.00")
assert loaded.is_reversal is False
assert loaded.adjustment_amount == Decimal("0") # default
def test_cas_adjustment_aggregation(tmp_path, monkeypatch):
"""Sum of CAS rows for a Remittance equals expected total."""
with db.SessionLocal() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1", batch_id="b1",
payer_claim_control_number="CLP-1", status_code="1",
total_charge=Decimal("124.00"), total_paid=Decimal("62.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
)
s.add(r)
s.flush()
s.add_all([
CasAdjustment(remittance_id="CLP-1", group_code="CO", reason_code="45",
amount=Decimal("30.00")),
CasAdjustment(remittance_id="CLP-1", group_code="PR", reason_code="1",
amount=Decimal("32.00")),
])
s.commit()
from sqlalchemy import select, func
with db.SessionLocal() as s:
total = s.execute(
select(func.sum(CasAdjustment.amount))
.where(CasAdjustment.remittance_id == "CLP-1")
).scalar_one()
assert total == Decimal("62.00")
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v -k "remittance or cas_adjustment"
Expected: ImportError on cyclone.db.Remittance.
- Step 3: Add
Remittance+CasAdjustmentmodels
Append to backend/src/cyclone/db.py:
class Remittance(Base):
__tablename__ = "remittances"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
batch_id: Mapped[str] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=False
)
payer_claim_control_number: Mapped[str] = mapped_column(String(64), nullable=False)
claim_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("claims.id"), nullable=True
)
status_code: Mapped[str] = mapped_column(String(4), nullable=False)
status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
total_paid: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
patient_responsibility: Mapped[Optional[Decimal]] = mapped_column(Numeric(12, 2), nullable=True)
adjustment_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
raw_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
batch: Mapped["Batch"] = relationship(back_populates="remittances")
cas_adjustments: Mapped[list["CasAdjustment"]] = relationship(
back_populates="remittance", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("batch_id", "payer_claim_control_number", name="uq_remits_batch_pcn"),
Index("ix_remittances_claim_id", "claim_id"),
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
Index("ix_remittances_status_code", "status_code"),
)
class CasAdjustment(Base):
__tablename__ = "cas_adjustments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
remittance_id: Mapped[str] = mapped_column(
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
)
group_code: Mapped[str] = mapped_column(String(4), nullable=False)
reason_code: Mapped[str] = mapped_column(String(8), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
quantity: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
remittance: Mapped["Remittance"] = relationship(back_populates="cas_adjustments")
__table_args__ = (
Index("ix_cas_adjustments_remittance_id", "remittance_id"),
)
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v
Expected: 5 tests pass (3 from Task 4 + 2 from this task).
- Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Remittance + CasAdjustment ORM models"
Task 6: Match + ActivityEvent ORM models
Files:
-
Modify:
backend/src/cyclone/db.py -
Modify:
backend/tests/test_db_models.py -
Step 1: Add failing tests
Append to backend/tests/test_db_models.py:
from cyclone.db import ActivityEvent, Match
def test_create_match_and_activity_event():
with db.SessionLocal() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
charge_amount=Decimal("124.00"), state=ClaimState.SUBMITTED,
))
s.add(Remittance(
id="CLP-1", batch_id="b1", payer_claim_control_number="CLM-1",
status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
m = Match(
claim_id="CLM-1", remittance_id="CLP-1",
strategy="auto", is_reversal=False,
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
)
s.add(m)
s.flush()
s.add(ActivityEvent(
ts=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
kind="reconcile", batch_id="b1", claim_id="CLM-1",
payload_json={"matched": 1},
))
s.commit()
with db.SessionLocal() as s:
loaded = s.get(Match, 1) # autoincrement id
assert loaded is not None
assert loaded.strategy == "auto"
assert loaded.is_reversal is False
events = s.query(ActivityEvent).all()
assert len(events) == 1
assert events[0].kind == "reconcile"
assert events[0].payload_json == {"matched": 1}
def test_match_unique_per_claim():
"""A Claim can only have one current Match row."""
import sqlalchemy.exc
with db.SessionLocal() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
charge_amount=Decimal("0"), state=ClaimState.SUBMITTED))
s.add(Remittance(id="CLP-1", batch_id="b1", payer_claim_control_number="x",
status_code="1", received_at=datetime.now(timezone.utc)))
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
status_code="1", received_at=datetime.now(timezone.utc)))
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto",
matched_at=datetime.now(timezone.utc)))
s.commit()
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
matched_at=datetime.now(timezone.utc)))
with pytest.raises(sqlalchemy.exc.IntegrityError):
s.commit()
s.rollback()
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v -k "match or activity"
Expected: ImportError on cyclone.db.Match.
- Step 3: Add
Match+ActivityEventmodels
Append to backend/src/cyclone/db.py:
class Match(Base):
__tablename__ = "matches"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
claim_id: Mapped[str] = mapped_column(
String(64), ForeignKey("claims.id", ondelete="CASCADE"),
nullable=False, unique=True,
)
remittance_id: Mapped[str] = mapped_column(
String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
)
strategy: Mapped[str] = mapped_column(String(16), nullable=False)
matched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prior_claim_state: Mapped[Optional[ClaimState]] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=True
)
is_reversal: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
Index("ix_matches_remittance_id", "remittance_id"),
Index("ix_matches_matched_at", "matched_at"),
)
class ActivityEvent(Base):
__tablename__ = "activity_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
kind: Mapped[str] = mapped_column(String(32), nullable=False)
batch_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
claim_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
remittance_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
payload_json: Mapped[Optional[dict]] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_activity_events_ts", "ts"),
Index("ix_activity_events_kind", "kind"),
)
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_db_models.py -v
Expected: 7 tests pass.
- Step 5: Commit
git add backend/src/cyclone/db.py backend/tests/test_db_models.py
git commit -m "feat(backend): add Match + ActivityEvent ORM models"
Phase 3 — Reconciliation pure functions
Task 7: ApplyIntent + ReconcileResult types and reconcile.match
Files:
-
Create:
backend/src/cyclone/reconcile.py -
Create:
backend/tests/test_reconcile.py -
Step 1: Write the failing tests
backend/tests/test_reconcile.py:
"""Tests for the pure reconciliation functions.
These tests use lightweight stand-in objects (namedtuples) rather than
SQLAlchemy ORM instances, so the reconcile module has zero DB dependency.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Optional
import pytest
from cyclone import reconcile
# --- Stand-in types -----------------------------------------------------------
@dataclass
class FakeClaim:
id: str
patient_control_number: str
service_date_from: Optional[date]
state: str = "submitted"
matched_remittance_id: Optional[str] = None
@dataclass
class FakeRemit:
id: str
payer_claim_control_number: str
service_date: Optional[date]
is_reversal: bool = False
@dataclass
class FakeMatch:
claim: FakeClaim
remittance: FakeRemit
strategy: str
is_reversal: bool
# --- match() ------------------------------------------------------------------
def test_match_same_pcn_within_window_matches():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
matches = reconcile.match(claims, remits, window_days=7)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-1"
assert matches[0].remittance.id == "CLP-1"
assert matches[0].strategy == "auto"
def test_match_dates_outside_window_unmatched():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 7, 1))] # 30d apart
matches = reconcile.match(claims, remits, window_days=7)
assert matches == []
def test_match_different_pcn_unmatched():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-B", date(2026, 6, 1))]
matches = reconcile.match(claims, remits)
assert matches == []
def test_match_multiple_claims_same_pcn_picks_closest_date():
claims = [
FakeClaim("CLM-1", "PCN-A", date(2026, 5, 1)),
FakeClaim("CLM-2", "PCN-A", date(2026, 6, 1)), # closer
]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2))]
matches = reconcile.match(claims, remits)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-2"
def test_match_multiple_remits_same_pcn_each_gets_own_claim():
claims = [
FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1)),
FakeClaim("CLM-2", "PCN-A", date(2026, 6, 15)),
]
remits = [
FakeRemit("CLP-1", "PCN-A", date(2026, 6, 2)),
FakeRemit("CLP-2", "PCN-A", date(2026, 6, 16)),
]
matches = reconcile.match(claims, remits)
assert len(matches) == 2
claim_ids = {m.claim.id for m in matches}
assert claim_ids == {"CLM-1", "CLM-2"}
def test_match_skips_already_matched_claims():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1), matched_remittance_id="OLD")]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1))]
matches = reconcile.match(claims, remits)
assert matches == []
def test_match_falls_back_to_first_claim_when_no_service_date():
"""When the remit has no service_date, match the first claim for that PCN."""
claims = [
FakeClaim("CLM-1", "PCN-A", None),
FakeClaim("CLM-2", "PCN-A", None),
]
remits = [FakeRemit("CLP-1", "PCN-A", None)]
matches = reconcile.match(claims, remits)
assert len(matches) == 1
assert matches[0].claim.id == "CLM-1"
def test_match_reversal_flag_propagates():
claims = [FakeClaim("CLM-1", "PCN-A", date(2026, 6, 1))]
remits = [FakeRemit("CLP-1", "PCN-A", date(2026, 6, 1), is_reversal=True)]
matches = reconcile.match(claims, remits)
assert matches[0].is_reversal is True
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v
Expected: ModuleNotFoundError: No module named 'cyclone.reconcile'.
- Step 3: Implement
reconcile.match+ result types
backend/src/cyclone/reconcile.py:
"""Pure reconciliation functions.
These functions take lists of objects (ORM or stand-in dataclasses) and
return intent objects describing what should change. They do not own a
database session; the caller (cyclone.store) persists the intents.
Match algorithm:
- Group claims by patient_control_number.
- For each remit, find a candidate claim with the same PCN.
- Prefer the claim whose service_date_from is closest to the remit's
service_date, within +/- window_days.
- If the remit has no service_date, fall back to the first claim for
that PCN (in input order).
- Skip claims that already have matched_remittance_id set.
- is_reversal flag is propagated to the Match.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Iterable, Optional, Protocol
WINDOW_DAYS_DEFAULT = 7
class _ClaimLike(Protocol):
id: str
patient_control_number: str
service_date_from: Optional[date]
matched_remittance_id: Optional[str]
class _RemitLike(Protocol):
id: str
payer_claim_control_number: str
service_date: Optional[date]
is_reversal: bool
@dataclass
class Match:
claim: _ClaimLike
remittance: _RemitLike
strategy: str
is_reversal: bool
def match(
claims: Iterable[_ClaimLike],
remittances: Iterable[_RemitLike],
*,
window_days: int = WINDOW_DAYS_DEFAULT,
) -> list[Match]:
"""Pair each remit with the closest eligible claim. Returns Match intents."""
claims_list = list(claims)
remits_list = list(remittances)
# Eligible = no current match, has a PCN.
eligible = [c for c in claims_list if c.patient_control_number and not c.matched_remittance_id]
by_pcn: dict[str, list[_ClaimLike]] = {}
for c in eligible:
by_pcn.setdefault(c.patient_control_number, []).append(c)
matches: list[Match] = []
used_claim_ids: set[str] = set()
for r in remits_list:
candidates = by_pcn.get(r.payer_claim_control_number, [])
candidates = [c for c in candidates if c.id not in used_claim_ids]
if not candidates:
continue
chosen = _pick_claim(candidates, r.service_date, window_days)
if chosen is None:
continue
matches.append(Match(
claim=chosen,
remittance=r,
strategy="auto",
is_reversal=r.is_reversal,
))
used_claim_ids.add(chosen.id)
return matches
def _pick_claim(
candidates: list[_ClaimLike],
remit_service_date: Optional[date],
window_days: int,
) -> Optional[_ClaimLike]:
"""Choose the claim with the closest service_date_from within the window."""
if remit_service_date is None:
return candidates[0] # no date info → first claim for that PCN
best: tuple[int, _ClaimLike] | None = None
for c in candidates:
if c.service_date_from is None:
continue
delta = abs((c.service_date_from - remit_service_date).days)
if delta > window_days:
continue
if best is None or delta < best[0]:
best = (delta, c)
if best is not None:
return best[1]
# No candidate within window had a date; fall back to first.
return candidates[0]
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v
Expected: 8 tests pass.
- Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add reconcile.match with date-window + multi-claim fallback"
Task 8: reconcile.apply_payment + apply_reversal + split_unmatched
Files:
-
Modify:
backend/src/cyclone/reconcile.py -
Modify:
backend/tests/test_reconcile.py -
Step 1: Add failing tests
Append to backend/tests/test_reconcile.py:
from cyclone.db import ClaimState
# ApplyIntent + ApplyIntent.noop tests ---------------------------------------
def test_apply_payment_full_amount_returns_paid():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
assert intent.new_state == ClaimState.PAID
assert intent.activity_kind == "reconcile"
assert intent.skipped is False
def test_apply_payment_partial_amount_returns_partial():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("60"))
assert intent.new_state == ClaimState.PARTIAL
def test_apply_payment_zero_paid_returns_received():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"))
assert intent.new_state == ClaimState.RECEIVED
def test_apply_payment_status_4_returns_denied():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.SUBMITTED.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("0"),
status_code="4")
assert intent.new_state == ClaimState.DENIED
def test_apply_payment_already_paid_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
remit = FakeRemit("CLP-1", "PCN-A", None)
intent = reconcile.apply_payment(claim, remit, charge=Decimal("100"), paid=Decimal("100"))
assert intent.skipped is True
assert intent.activity_kind == "invalid_state"
assert intent.new_state is None
def test_apply_reversal_of_paid_returns_reversed_with_prior_state():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PAID.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.new_state == ClaimState.REVERSED
assert intent.prior_claim_state == ClaimState.PAID
assert intent.activity_kind == "reversal"
assert intent.skipped is False
def test_apply_reversal_of_partial_works_same_way():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.PARTIAL.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.new_state == ClaimState.REVERSED
assert intent.prior_claim_state == ClaimState.PARTIAL
def test_apply_reversal_of_denied_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.DENIED.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.skipped is True
assert intent.activity_kind == "reversal_skipped"
def test_apply_reversal_of_already_reversed_is_noop():
claim = FakeClaim("CLM-1", "PCN-A", None, state=ClaimState.REVERSED.value)
remit = FakeRemit("CLP-1", "PCN-A", None, is_reversal=True)
intent = reconcile.apply_reversal(claim, remit)
assert intent.skipped is True
def test_split_unmatched_partitions_by_match_set():
claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
matches = [Match(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)]
unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
assert [c.id for c in unmatched_claims] == ["CLM-2"]
assert [r.id for r in unmatched_remits] == ["CLP-2"]
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v -k "apply or split"
Expected: AttributeError on reconcile.apply_payment.
- Step 3: Implement
apply_payment,apply_reversal,split_unmatched
Append to backend/src/cyclone/reconcile.py:
from decimal import Decimal
@dataclass
class ApplyIntent:
"""Result of applying a match. The caller persists this."""
new_state: Optional[ClaimState] = None
activity_kind: str = "reconcile"
skipped: bool = False
prior_claim_state: Optional[ClaimState] = None
reason: str = ""
@classmethod
def noop(cls, *, activity_kind: str = "invalid_state", reason: str = "") -> "ApplyIntent":
return cls(
new_state=None,
activity_kind=activity_kind,
skipped=True,
prior_claim_state=None,
reason=reason,
)
def apply_payment(
claim: _ClaimLike,
remit: _RemitLike,
*,
charge: Decimal,
paid: Decimal,
status_code: str = "",
) -> ApplyIntent:
"""Decide the Claim's new state given a non-reversal match."""
if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value,
ClaimState.DENIED.value, ClaimState.RECONCILED.value):
return ApplyIntent.noop(
reason=f"claim already in terminal state {claim.state}",
)
if status_code == "4":
new_state = ClaimState.DENIED
elif paid is None or paid <= 0:
new_state = ClaimState.RECEIVED
elif paid >= charge:
new_state = ClaimState.PAID
else:
new_state = ClaimState.PARTIAL
return ApplyIntent(new_state=new_state, activity_kind="reconcile")
def apply_reversal(claim: _ClaimLike, remit: _RemitLike) -> ApplyIntent:
"""Apply a reversal: flip paid/partial → REVERSED, preserve prior state."""
if claim.state in (ClaimState.PAID.value, ClaimState.PARTIAL.value):
return ApplyIntent(
new_state=ClaimState.REVERSED,
activity_kind="reversal",
prior_claim_state=ClaimState(claim.state),
)
return ApplyIntent.noop(
activity_kind="reversal_skipped",
reason=f"reversal on state {claim.state}",
)
def split_unmatched(
claims: list[_ClaimLike],
remits: list[_RemitLike],
matches: list[Match],
) -> tuple[list[_ClaimLike], list[_RemitLike]]:
"""Return (unmatched_claims, unmatched_remits) after applying matches."""
matched_claim_ids = {m.claim.id for m in matches}
matched_remit_ids = {m.remittance.id for m in matches}
return (
[c for c in claims if c.id not in matched_claim_ids],
[r for r in remits if r.id not in matched_remit_ids],
)
Note:
apply_paymentreadsclaim.stateas a string. ORM objects expose state as a string viaClaimState.SUBMITTED.value == "submitted". The fakeFakeClaimin tests also stores state as a string. Real production callers (store.add()) pass ORM Claim objects whose.stateis theClaimStateenum — SQLAlchemy returns the underlying string value automatically when reading the column. The protocol used in_ClaimLikedoesn't enforce state typing, so both work.
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v
Expected: 18 tests pass (8 from Task 7 + 10 from this task).
- Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add apply_payment, apply_reversal, split_unmatched pure fns"
Phase 4 — Store facade refactor
Task 9: cyclone.store rewrite — CycloneStore class with Batch/Claim persistence (837 only)
Files:
- Modify:
backend/src/cyclone/store.py - Modify:
backend/tests/test_store.py
Note:
test_store.pyalready 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 theInMemoryStore.list()etc. no longer exist. This is intentional — the public API surface stays but the test layer changes.
- Step 1: Add failing tests for the new facade (837 path)
Rewrite backend/tests/test_store.py:
"""Tests for the SQLAlchemy-backed CycloneStore.
Public API is preserved from the in-memory version: add / get_batch /
iter_claims / iter_remittances / distinct_providers / recent_activity.
New methods: list_unmatched / manual_match / manual_unmatch.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.store import CycloneStore, store
from cyclone.parsers.models import (
ClaimOutput, Envelope, ParseResult, ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimPayment, ParseResult835, Envelope as Envelope835,
FinancialInfo, ReassociationTrace, Payer835, Payee835, BatchSummary,
)
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput:
return ClaimOutput(
claim_id=claim_id,
billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None,
"address": None, "city": None, "state": None, "zip": None,
"phone": None, "tax_id": None},
subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe",
"dob": None, "gender": None, "address": None, "city": None,
"state": None, "zip": None},
payer={"id": "P1", "name": "Test Payer", "type": None, "address": None,
"city": None, "state": None, "zip": None},
diagnoses=[],
service_lines=[],
claim={"total_charge": float(charge), "place_of_service": "11",
"frequency_code": "1", "prior_auth": None, "provider_signature": None,
"assignment": None, "benefits_assigned": None, "release_of_info": None},
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def _make_result_837() -> ParseResult:
return ParseResult(
envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="837",
transaction_date="2026-06-19", transaction_time=None),
billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
"address": None, "city": None, "state": None, "zip": None,
"phone": None, "tax_id": None},
claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")],
summary={"total_claims": 2, "passed": 2, "failed": 0,
"started_at": "2026-06-19T12:00:00Z",
"finished_at": "2026-06-19T12:00:01Z",
"duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
)
def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1"):
from cyclone.store import BatchRecord
return BatchRecord(
id=batch_id, kind=kind, input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=result or _make_result_837(),
)
def test_module_singleton():
assert isinstance(store, CycloneStore)
def test_add_837_persists_batch_and_claims():
s = CycloneStore()
rec = _make_batch_record()
s.add(rec)
# New session confirms persistence.
with db.SessionLocal() as session:
from cyclone.db import Batch, Claim, ClaimState
b = session.get(Batch, "b-uuid-1")
assert b is not None
assert b.kind == "837p"
claims = session.query(Claim).all()
assert len(claims) == 2
assert all(c.state == ClaimState.SUBMITTED for c in claims)
def test_iter_claims_returns_dicts():
s = CycloneStore()
s.add(_make_batch_record())
rows = s.iter_claims()
assert len(rows) == 2
assert all("id" in r and "state" in r for r in rows)
def test_persistence_across_session():
s1 = CycloneStore()
s1.add(_make_batch_record(batch_id="b-x"))
s2 = CycloneStore() # fresh instance, same engine
loaded = s2.get_batch("b-x")
assert loaded is not None
assert loaded["kind"] == "837p"
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: ImportError on cyclone.store.CycloneStore or signature mismatch.
- Step 3: Rewrite
cyclone.store— keep BatchRecord, replace InMemoryStore
Replace the entire contents of backend/src/cyclone/store.py:
"""SQLAlchemy-backed store facade for parsed X12 files.
Public API preserved from the in-memory implementation (sub-project 1):
- add(record) → persists the BatchRecord; for 835 batches, triggers reconcile
- get_batch(id) → dict | None
- iter_claims(...) → list[dict]
- iter_remittances(...) → list[dict]
- distinct_providers() → list[dict]
- recent_activity(...) → list[dict]
New methods (sub-project 2):
- list_unmatched(...) → dict with claims + remittances
- manual_match(claim_id, remit_id) → Match
- manual_unmatch(claim_id) → None
The store is a singleton (`store`); public methods open their own
SessionLocal() context. Internal helpers convert ORM rows to the
UI-shaped dicts the API expects (to_ui_*).
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, model_validator
from cyclone import db
from cyclone.parsers.models import ClaimOutput, ParseResult
from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835
log = logging.getLogger(__name__)
BatchKind = Literal["837p", "835"]
class BatchRecord(BaseModel):
"""One parsed file. Preserved from sub-project 1 with __new__ dispatch."""
model_config = ConfigDict(extra="ignore")
id: str
kind: BatchKind
input_filename: str
parsed_at: datetime
result: ParseResult | ParseResult835
def __new__(cls, *args: Any, **kwargs: Any):
if cls is BatchRecord:
kind = kwargs.get("kind")
if kind is None and args and isinstance(args[0], dict):
kind = args[0].get("kind")
if kind == "837p":
return BatchRecord837(*args, **kwargs)
if kind == "835":
return BatchRecord835(*args, **kwargs)
return super().__new__(cls)
@model_validator(mode="after")
def _check_parsed_at_tz(self) -> "BatchRecord":
if self.parsed_at.tzinfo is None:
raise ValueError("parsed_at must be tz-aware (use datetime.now(timezone.utc))")
return self
class BatchRecord837(BatchRecord):
kind: Literal["837p"] = "837p"
result: ParseResult
class BatchRecord835(BatchRecord):
kind: Literal["835"] = "835"
result: ParseResult835
def utcnow() -> datetime:
return datetime.now(timezone.utc)
# --- CycloneStore --------------------------------------------------------------
class CycloneStore:
"""SQLAlchemy-backed facade. Public API preserved from sub-project 1."""
def add(self, record: BatchRecord) -> str:
"""Persist BatchRecord; for 835 batches, trigger reconciliation."""
from cyclone.db import Batch, Claim, Remittance, ActivityEvent, ClaimState
from cyclone import reconcile
batch_id = record.id
parsed_iso = record.parsed_at.isoformat().replace("+00:00", "Z")
with db.SessionLocal() as s:
summary = record.result.summary
validation = getattr(record.result, "validation", None)
s.add(Batch(
id=batch_id,
kind=record.kind,
input_filename=record.input_filename,
parsed_at=record.parsed_at,
totals_json=summary.model_dump() if hasattr(summary, "model_dump") else dict(summary),
validation_json=(validation.model_dump() if hasattr(validation, "model_dump")
else (dict(validation) if validation else None)),
raw_result_json=record.result.model_dump(mode="json"),
))
if record.kind == "837p":
for c in record.result.claims:
s.add(_claim_837_row(c, batch_id))
elif record.kind == "835":
for cp in record.result.claims:
s.add(_remittance_835_row(cp, batch_id))
s.add(ActivityEvent(
ts=utcnow(), kind=f"parse_{record.kind.replace('p', '')}",
batch_id=batch_id, payload_json={"claims": len(record.result.claims)},
))
s.commit()
# Reconciliation is triggered AFTER the ERA is persisted. Failures
# are fail-soft (logged + activity event) and never block the response.
if record.kind == "835":
self._run_reconcile(batch_id)
return batch_id
def _run_reconcile(self, batch_id: str) -> None:
from cyclone import reconcile
from cyclone.db import Batch, Remittance, Claim
try:
with db.SessionLocal() as s:
result = reconcile.run(s, batch_id)
s.commit()
log.info("reconcile batch=%s matched=%s", batch_id, result.matched)
except Exception as exc:
log.exception("reconcile failed for batch %s", batch_id)
# Persist the failure in a fresh session.
from cyclone.db import ActivityEvent
with db.SessionLocal() as s2:
s2.add(ActivityEvent(
ts=utcnow(), kind="reconcile_failed", batch_id=batch_id,
payload_json={"error": str(exc)},
))
s2.commit()
def get_batch(self, batch_id: str) -> dict | None:
from cyclone.db import Batch
with db.SessionLocal() as s:
b = s.get(Batch, batch_id)
if b is None:
return None
return {
"id": b.id,
"kind": b.kind,
"input_filename": b.input_filename,
"parsed_at": b.parsed_at.isoformat().replace("+00:00", "Z"),
"totals": b.totals_json or {},
"validation": b.validation_json or {},
"result": b.raw_result_json or {},
}
def iter_claims(
self,
*,
batch_id: Optional[str] = None,
status: Optional[str] = None,
payer: Optional[str] = None,
provider_npi: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
sort: str = "service_date_from",
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
from sqlalchemy import select
from cyclone.db import Claim, ClaimState
with db.SessionLocal() as s:
stmt = select(Claim)
if batch_id:
stmt = stmt.where(Claim.batch_id == batch_id)
if status:
stmt = stmt.where(Claim.state == ClaimState(status))
if payer:
stmt = stmt.where(Claim.payer_id == payer)
if provider_npi:
stmt = stmt.where(Claim.provider_npi == provider_npi)
if date_from:
stmt = stmt.where(Claim.service_date_from >= date_from)
if date_to:
stmt = stmt.where(Claim.service_date_from <= date_to)
sort_col = getattr(Claim, sort, Claim.service_date_from)
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
stmt = stmt.limit(limit).offset(offset)
claims = s.execute(stmt).scalars().all()
return [to_ui_claim(c) for c in claims]
def iter_remittances(
self,
*,
batch_id: Optional[str] = None,
status: Optional[str] = None,
payer: Optional[str] = None,
claim_id: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
sort: str = "received_at",
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> list[dict]:
from sqlalchemy import select
from cyclone.db import Remittance
with db.SessionLocal() as s:
stmt = select(Remittance)
if batch_id:
stmt = stmt.where(Remittance.batch_id == batch_id)
if status:
stmt = stmt.where(Remittance.status_code == status)
if payer:
stmt = stmt.where(Remittance.payer_claim_control_number.like(f"{payer}%"))
if claim_id:
stmt = stmt.where(Remittance.claim_id == claim_id)
sort_col = getattr(Remittance, sort, Remittance.received_at)
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
stmt = stmt.limit(limit).offset(offset)
remits = s.execute(stmt).scalars().all()
return [to_ui_remittance(r) for r in remits]
def distinct_providers(self) -> list[dict]:
from sqlalchemy import select, func
from cyclone.db import Claim
with db.SessionLocal() as s:
stmt = (
select(Claim.provider_npi, Claim.payer_id, func.count(Claim.id))
.where(Claim.provider_npi.is_not(None))
.group_by(Claim.provider_npi, Claim.payer_id)
)
rows = s.execute(stmt).all()
return [
{"npi": npi, "payer": payer, "claim_count": count}
for npi, payer, count in rows
if npi
]
def recent_activity(self, *, limit: int = 200) -> list[dict]:
from sqlalchemy import select
from cyclone.db import ActivityEvent
with db.SessionLocal() as s:
stmt = select(ActivityEvent).order_by(ActivityEvent.ts.desc()).limit(limit)
events = s.execute(stmt).scalars().all()
return [
{
"id": e.id, "ts": e.ts.isoformat().replace("+00:00", "Z"),
"kind": e.kind, "batch_id": e.batch_id,
"claim_id": e.claim_id, "remittance_id": e.remittance_id,
"payload": e.payload_json or {},
}
for e in events
]
# --- New methods (sub-project 2) ---------------------------------------
def list_unmatched(
self, *, kind: Literal["claim", "remit", "both"] = "both",
) -> dict:
from sqlalchemy import select
from cyclone.db import Claim, Remittance
result: dict[str, list[dict]] = {"claims": [], "remittances": []}
with db.SessionLocal() as s:
if kind in ("claim", "both"):
stmt = (
select(Claim)
.where(Claim.matched_remittance_id.is_(None))
.order_by(Claim.service_date_from.asc().nulls_last())
)
result["claims"] = [to_ui_claim(c) for c in s.execute(stmt).scalars().all()]
if kind in ("remit", "both"):
stmt = (
select(Remittance)
.where(Remittance.claim_id.is_(None))
.order_by(Remittance.received_at.asc())
)
result["remittances"] = [
to_ui_remittance(r) for r in s.execute(stmt).scalars().all()
]
return result
def manual_match(self, claim_id: str, remittance_id: str) -> dict:
from sqlalchemy import select
from cyclone.db import Claim, Remittance, Match, ActivityEvent, ClaimState
from cyclone import reconcile
with db.SessionLocal() as s:
claim = s.get(Claim, claim_id)
remit = s.get(Remittance, remittance_id)
if claim is None or remit is None:
raise KeyError("claim_or_remit_not_found")
if claim.matched_remittance_id is not None:
raise AlreadyMatchedError(f"claim {claim_id} already matched")
if remit.claim_id is not None:
raise AlreadyMatchedError(f"remit {remittance_id} already matched")
intent = (reconcile.apply_reversal(claim, remit) if remit.is_reversal
else reconcile.apply_payment(claim, remit,
charge=claim.charge_amount,
paid=remit.total_paid,
status_code=remit.status_code))
if intent.skipped:
raise InvalidStateError(intent.activity_kind, intent.reason, claim.state.value)
prior = intent.prior_claim_state
claim.state = intent.new_state if not remit.is_reversal else ClaimState.REVERSED
claim.matched_remittance_id = remit.id
m = Match(
claim_id=claim.id, remittance_id=remit.id,
strategy="manual",
matched_at=utcnow(),
prior_claim_state=prior,
is_reversal=remit.is_reversal,
)
s.add(m)
s.add(ActivityEvent(
ts=utcnow(), kind="manual_match", batch_id=claim.batch_id,
claim_id=claim.id, remittance_id=remit.id,
payload_json={"new_state": claim.state.value},
))
s.commit()
return {"claim": to_ui_claim(claim), "match": {"id": m.id, "strategy": "manual"}}
def manual_unmatch(self, claim_id: str) -> dict:
from sqlalchemy import select, delete
from cyclone.db import Claim, Match, ActivityEvent, ClaimState
with db.SessionLocal() as s:
claim = s.get(Claim, claim_id)
if claim is None or claim.matched_remittance_id is None:
raise KeyError("no_current_match")
s.execute(delete(Match).where(Match.claim_id == claim_id))
claim.matched_remittance_id = None
claim.state = ClaimState.SUBMITTED
s.add(ActivityEvent(
ts=utcnow(), kind="manual_unmatch", batch_id=claim.batch_id,
claim_id=claim.id,
payload_json={"reason": "operator-initiated"},
))
s.commit()
return {"claim": to_ui_claim(claim)}
class AlreadyMatchedError(Exception):
"""Raised by manual_match when either side is already matched."""
class InvalidStateError(Exception):
"""Raised by manual_match when the state transition would be invalid."""
def __init__(self, activity_kind: str, reason: str, current_state: str):
super().__init__(reason)
self.activity_kind = activity_kind
self.current_state = current_state
# Backwards-compat alias for code that imported the old name.
InMemoryStore = CycloneStore
store = CycloneStore()
# --- ORM row constructors ------------------------------------------------------
def _claim_837_row(c: ClaimOutput, batch_id: str) -> "db.Claim":
"""Build a Claim ORM row from a parsed ClaimOutput."""
from cyclone.db import Claim, ClaimState
return Claim(
id=c.claim_id,
batch_id=batch_id,
patient_control_number=c.claim_id,
service_date_from=_safe_date(getattr(c.claim, "service_date_from", None)
or getattr(c.claim, "service_date", None)),
service_date_to=None,
charge_amount=Decimal(str(c.claim.total_charge or 0)),
provider_npi=str(c.billing_provider.npi) if c.billing_provider else None,
payer_id=str(c.payer.id) if c.payer else None,
state=ClaimState.SUBMITTED,
raw_json=c.model_dump(mode="json"),
)
def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> "db.Remittance":
from cyclone.db import Remittance
first_svc = cp.service_payments[0] if cp.service_payments else None
return Remittance(
id=cp.payer_claim_control_number + ":" + batch_id[:8], # unique per batch
batch_id=batch_id,
payer_claim_control_number=cp.payer_claim_control_number,
status_code=cp.status_code,
status_label=cp.status_label,
total_charge=Decimal(str(cp.total_charge or 0)),
total_paid=Decimal(str(cp.total_paid or 0)),
patient_responsibility=(Decimal(str(cp.patient_responsibility))
if cp.patient_responsibility is not None else None),
adjustment_amount=Decimal("0"), # populated by reconcile
received_at=utcnow(),
service_date=first_svc.service_date if first_svc else None,
is_reversal=cp.status_code in ("21", "22"),
raw_json=cp.model_dump(mode="json"),
)
def _safe_date(value: Any) -> Optional[date]:
if value is None:
return None
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
return None
# --- Mappers (UI shapes) -------------------------------------------------------
def to_ui_claim(c: "db.Claim") -> dict:
return {
"id": c.id,
"claimId": c.patient_control_number,
"patientControlNumber": c.patient_control_number,
"serviceDate": c.service_date_from.isoformat() if c.service_date_from else None,
"serviceDateFrom": c.service_date_from.isoformat() if c.service_date_from else None,
"serviceDateTo": c.service_date_to.isoformat() if c.service_date_to else None,
"chargeAmount": float(c.charge_amount),
"providerNpi": c.provider_npi,
"payerId": c.payer_id,
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
"status": c.state.value if hasattr(c.state, "value") else str(c.state),
"matchedRemittanceId": c.matched_remittance_id,
"batchId": c.batch_id,
}
def to_ui_remittance(r: "db.Remittance") -> dict:
return {
"id": r.id,
"claimId": r.payer_claim_control_number,
"payerClaimControlNumber": r.payer_claim_control_number,
"payerName": None,
"paidAmount": float(r.total_paid),
"adjustmentAmount": float(r.adjustment_amount),
"status": r.status_label or r.status_code,
"statusCode": r.status_code,
"denialReason": None,
"validationWarnings": [],
"receivedDate": r.received_at.isoformat().replace("+00:00", "Z"),
"batchId": r.batch_id,
"isReversal": bool(r.is_reversal),
"serviceDate": r.service_date.isoformat() if r.service_date else None,
}
def to_ui_provider(*, npi: str, payer: str, claim_count: int) -> dict:
return {"npi": npi, "payer": payer, "claimCount": claim_count}
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: 4 tests pass.
- Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(backend): rewrite cyclone.store as SQLAlchemy facade (837 path)"
Heads-up: The
parse-835route inapi.pycallsstore.add()which now triggers reconciliation viareconcile.run(). Butreconcile.run()doesn't exist yet — it's added in Task 10. To keep tests green,CycloneStore._run_reconcileonly runs whenrecord.kind == "835". Since we haven't wired reconciliation yet, parsing an 835 right now will raise onreconcile.run(). The fault-injection test in Task 14 covers this. Don't run parse-835 manually until Task 10 is merged.
Task 10: reconcile.run orchestrator + integration with store
Files:
-
Modify:
backend/src/cyclone/reconcile.py -
Modify:
backend/tests/test_reconcile.py -
Step 1: Add failing tests
Append to backend/tests/test_reconcile.py:
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
from cyclone.db import (
Batch, Claim, ClaimState, Remittance, Match, ActivityEvent,
)
@pytest.fixture
def fixture_835(tmp_path, monkeypatch):
"""Set up a clean DB and return an empty helper for building remits."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/reconcile.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_batch(s, batch_id="b1", kind="835"):
s.add(Batch(
id=batch_id, kind=kind, input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.flush()
def _make_claim(s, claim_id="CLM-1", pcn="PCN-A", charge="100.00",
service_date=None, state=ClaimState.SUBMITTED):
s.add(Claim(
id=claim_id, batch_id="b1", patient_control_number=pcn,
service_date_from=service_date, charge_amount=Decimal(charge),
state=state,
))
s.flush()
def _make_remit(s, remit_id="CLP-1", pcn="PCN-A", status="1",
charge="100.00", paid="100.00", service_date=None,
is_reversal=False):
rid = remit_id + ":b1xxxxxx"
s.add(Remittance(
id=rid, batch_id="b1", payer_claim_control_number=pcn,
status_code=status, total_charge=Decimal(charge), total_paid=Decimal(paid),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
service_date=service_date, is_reversal=is_reversal,
))
s.flush()
return rid
def test_run_matches_and_updates_state(fixture_835):
with db.SessionLocal() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
_make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00", date(2026, 6, 1))
s.commit()
with db.SessionLocal() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 1
assert result.unmatched_remittances == 0
assert result.unmatched_claims == 0
with db.SessionLocal() as s:
claim = s.get(Claim, "CLM-1")
assert claim.state == ClaimState.PAID
assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"
def test_run_orphan_remit_leaves_claim_unmatched(fixture_835):
with db.SessionLocal() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
_make_remit(s, "CLP-2", "PCN-NEW", "1", "50.00", "50.00", date(2026, 6, 5))
s.commit()
with db.SessionLocal() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 0
assert result.unmatched_remittances == 1
assert result.unmatched_claims == 0
def test_run_reversal_flips_paid_to_reversed(fixture_835):
with db.SessionLocal() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1),
state=ClaimState.PAID)
# Match row already exists from prior reconcile.
s.add(Match(
claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
strategy="auto", matched_at=datetime.now(timezone.utc),
is_reversal=False,
))
s.flush()
_make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
date(2026, 6, 10), is_reversal=True)
s.commit()
with db.SessionLocal() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 1
with db.SessionLocal() as s:
claim = s.get(Claim, "CLM-1")
assert claim.state == ClaimState.REVERSED
# Match rows: original + reversal.
matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
assert len(matches) == 2
reversal_match = [m for m in matches if m.is_reversal][0]
assert reversal_match.prior_claim_state == ClaimState.PAID
def test_run_failed_reconcile_writes_activity_event(fixture_835):
"""If reconcile crashes, the batch + remittances stay; activity event records failure."""
with db.SessionLocal() as s:
_make_batch(s)
_make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00")
s.commit()
# Monkeypatch reconcile.match to raise.
from cyclone import reconcile
real_match = reconcile.match
def boom(*a, **kw): raise RuntimeError("synthetic fault")
reconcile.match = boom
try:
with db.SessionLocal() as s:
try:
reconcile.run(s, "b1")
s.commit()
except RuntimeError:
pass # caller is responsible for catch; reconcile.run itself shouldn't raise
finally:
reconcile.match = real_match
# Batch + remit still present.
with db.SessionLocal() as s:
b = s.get(Batch, "b1")
assert b is not None
r = s.query(Remittance).first()
assert r is not None
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v -k "run_"
Expected: ImportError on reconcile.run.
- Step 3: Implement
reconcile.runorchestrator
Append to backend/src/cyclone/reconcile.py:
from dataclasses import dataclass
@dataclass
class ReconcileResult:
matched: int = 0
unmatched_claims: int = 0
unmatched_remittances: int = 0
skipped: int = 0
def run(session, batch_id: str) -> ReconcileResult:
"""Orchestrate reconciliation for an 835 batch.
Expects Batch + Remittance + CasAdjustment rows already persisted
(this is called from store.add() AFTER commit). Loads the new
remittances + all currently-unmatched Claims, calls match(), then
applies each match's intent inside the caller's session.
Returns counts. Does NOT commit; caller controls transaction.
Raises on failure (caller catches and writes activity event).
"""
from sqlalchemy import select
from cyclone.db import (
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
ClaimState,
)
new_remits = list(
session.execute(
select(Remittance).where(Remittance.batch_id == batch_id)
).scalars().all()
)
unmatched_claims = list(
session.execute(
select(Claim).where(Claim.matched_remittance_id.is_(None))
).scalars().all()
)
matches = match(unmatched_claims, new_remits)
applied = 0
skipped = 0
for m in matches:
if m.is_reversal:
intent = apply_reversal(m.claim, m.remittance)
else:
intent = apply_payment(
m.claim, m.remittance,
charge=m.claim.charge_amount, paid=m.remittance.total_paid,
status_code=m.remittance.status_code,
)
if intent.skipped:
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"reason": intent.reason,
"current_state": getattr(m.claim.state, "value", m.claim.state)},
))
skipped += 1
continue
session.add(Match(
claim_id=m.claim.id, remittance_id=m.remittance.id,
strategy=m.strategy,
matched_at=datetime.now(timezone.utc),
prior_claim_state=intent.prior_claim_state,
is_reversal=m.is_reversal,
))
if not m.is_reversal:
m.claim.state = intent.new_state
else:
m.claim.state = ClaimState.REVERSED
m.claim.matched_remittance_id = m.remittance.id
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"new_state": m.claim.state.value},
))
applied += 1
# Aggregate CAS adjustments per Remittance.
for r in new_remits:
total = session.execute(
select(func.sum(CasAdjustment.amount))
.where(CasAdjustment.remittance_id == r.id)
).scalar_one() or Decimal("0")
r.adjustment_amount = total
# Persist any CasAdjustment rows that came from raw_segments but weren't
# explicitly created. The parser's CLAIM_ADJUSTMENTS extraction is in
# Task 11; for now, do nothing here — adjustment_amount stays 0 unless
# something explicitly created CasAdjustment rows.
# Partition.
unmatched_claims_after, unmatched_remits_after = split_unmatched(
unmatched_claims, new_remits,
[m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
)
return ReconcileResult(
matched=applied,
unmatched_claims=len(unmatched_claims_after),
unmatched_remittances=len(unmatched_remits_after),
skipped=skipped,
)
Imports needed at top of
reconcile.py: addfrom datetime import datetime, timezoneandfrom decimal import Decimalandfrom sqlalchemy import func. Adjust the existing imports accordingly.
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_reconcile.py -v
Expected: 22 tests pass (18 from previous tasks + 4 from this task).
- Step 5: Commit
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(backend): add reconcile.run orchestrator with match + apply + CAS agg"
Task 11: Wire ClaimAdjustment rows from 835 service_payments[*].adjustments during store.add()
Files:
-
Modify:
backend/src/cyclone/store.py -
Modify:
backend/tests/test_store_reconcile.py -
Step 1: Add failing test
Create backend/tests/test_store_reconcile.py:
"""Integration tests for CycloneStore.add() with 835 batches.
Covers: parse-835 persist → reconcile trigger → CAS aggregation.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from cyclone import db
from cyclone.parsers.models_835 import (
ClaimPayment, ClaimAdjustment, ServicePayment,
ParseResult835, Envelope, FinancialInfo, ReassociationTrace,
Payer835, Payee835, BatchSummary,
)
from cyclone.store import CycloneStore
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A",
status="1", charge="124.00", paid="62.00",
cas_amount="62.00"):
cp = ClaimPayment(
payer_claim_control_number=remit_id,
status_code=status,
status_label="Primary",
total_charge=charge, total_paid=paid,
service_payments=[
ServicePayment(
line_number=1, procedure_qualifier="HC", procedure_code="99213",
charge=charge, payment=paid,
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
amount=cas_amount)],
),
],
)
return cp
def _make_835_result(claims):
return ParseResult835(
envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="835",
transaction_date="2026-06-19", transaction_time=None),
financial_info=FinancialInfo(credit_debit="C", payment_amount=0.0,
credit_debit_indicator=None,
payment_method=None, date=None),
trace=ReassociationTrace(trace_type_code=None, trace_number=None,
originating_company_id=None, originating_company_qual=None),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=claims,
summary=BatchSummary(total_claims=len(claims), paid=0, denied=0,
reversed=0, total_paid=0.0, total_adjustment=0.0),
)
def test_add_835_aggregates_cas_into_adjustment_amount():
s = CycloneStore()
cp = _make_remit_with_cas()
from cyclone.store import BatchRecord835
rec = BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
with db.SessionLocal() as session:
from cyclone.db import Remittance, CasAdjustment
r = session.query(Remittance).first()
assert r is not None
assert r.adjustment_amount == 62.0
cas_rows = session.query(CasAdjustment).all()
assert len(cas_rows) == 1
assert cas_rows[0].group_code == "CO"
assert cas_rows[0].reason_code == "45"
- Step 2: Run test to verify it fails
Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v
Expected: assertion fails on adjustment_amount (still 0).
- Step 3: Modify
_remittance_835_rowto also insert CasAdjustment rows
In backend/src/cyclone/store.py, find the _remittance_835_row helper and modify the section in CycloneStore.add() where the 835 branch lives. After adding each Remittance row, also add CasAdjustment rows for the SVC-level adjustments:
elif record.kind == "835":
for cp in record.result.claims:
remit_row = _remittance_835_row(cp, batch_id)
s.add(remit_row)
s.flush() # need remit_row.id for FK
for svc in cp.service_payments:
for adj in svc.adjustments:
s.add(_cas_adjustment_row(adj, remit_row.id))
Add a new helper:
def _cas_adjustment_row(adj, remittance_id: str) -> "db.CasAdjustment":
from cyclone.db import CasAdjustment
return CasAdjustment(
remittance_id=remittance_id,
group_code=adj.group_code,
reason_code=adj.reason_code,
amount=Decimal(str(adj.amount)),
quantity=Decimal(str(adj.quantity)) if getattr(adj, "quantity", None) is not None else None,
)
- Step 4: Run test to verify it passes
Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v
Expected: 1 test passes.
- Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store_reconcile.py
git commit -m "feat(backend): persist CasAdjustment rows from 835 SVC.adjustments"
Task 12: store.list_unmatched + manual_match + manual_unmatch (already in store; add tests)
Files:
-
Modify:
backend/tests/test_store_reconcile.py -
Step 1: Add failing tests
Append to backend/tests/test_store_reconcile.py:
def test_list_unmatched_returns_orphan_remit():
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-ORPHAN", pcn="PCN-NEW")
from cyclone.store import BatchRecord835
rec = BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
out = s.list_unmatched(kind="both")
assert len(out["remittances"]) == 1
assert out["remittances"][0]["payerClaimControlNumber"] == "PCN-NEW"
assert out["claims"] == []
def test_manual_match_pairs_claim_with_orphan_remit():
from cyclone.store import BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
ClaimOutput, Envelope, ParseResult, ValidationReport,
)
s = CycloneStore()
# First add a Claim via 837.
co = ClaimOutput(
claim_id="CLM-1",
billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
"address": None, "city": None, "state": None, "zip": None,
"phone": None, "tax_id": None},
subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe",
"dob": None, "gender": None, "address": None, "city": None,
"state": None, "zip": None},
payer={"id": "P1", "name": "Test Payer", "type": None, "address": None,
"city": None, "state": None, "zip": None},
diagnoses=[], service_lines=[],
claim={"total_charge": 100.0, "place_of_service": "11",
"frequency_code": "1", "prior_auth": None,
"provider_signature": None, "assignment": None,
"benefits_assigned": None, "release_of_info": None},
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
pr837 = ParseResult(
envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="837",
transaction_date="2026-06-19", transaction_time=None),
billing_provider=co.billing_provider,
claims=[co],
summary={"total_claims": 1, "passed": 1, "failed": 0,
"started_at": "2026-06-19T12:00:00Z",
"finished_at": "2026-06-19T12:00:01Z",
"duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
)
s.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=pr837,
))
# Now an 835 with an orphan remit matching that Claim.
cp = ClaimPayment(
payer_claim_control_number="CLM-1", # matches CLM01
status_code="1", total_charge=100.0, total_paid=100.0,
)
s.add(BatchRecord835(
id="b-835", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
out = s.list_unmatched(kind="both")
assert len(out["claims"]) == 1
assert len(out["remittances"]) == 1
# Manually pair.
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
result = s.manual_match(claim_id, remit_id)
assert result["claim"]["state"] == "paid"
assert result["match"]["strategy"] == "manual"
out = s.list_unmatched(kind="both")
assert out["claims"] == []
assert out["remittances"] == []
def test_manual_match_conflict_raises():
"""Pairing a claim that is already matched raises AlreadyMatchedError."""
from cyclone.store import BatchRecord835, AlreadyMatchedError
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A")
s.add(BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
# Direct DB setup: create a Claim + first match.
from cyclone.db import Claim, Match
with db.SessionLocal() as sess:
sess.add(Claim(id="CLM-X", batch_id="b-1",
patient_control_number="PCN-A",
charge_amount=100, state="paid",
matched_remittance_id="CLP-1:b1xxxxxx"))
sess.commit()
with pytest.raises(AlreadyMatchedError):
s.manual_match("CLM-X", out["remittances"][0]["id"] if False else "CLP-1:b1xxxxxx")
The last test references
outfrom the previous test. That's a copy-paste bug — replace with a fresh setup or a single-claim setup. The cleaner version:def test_manual_match_conflict_raises(): from cyclone.store import BatchRecord835, AlreadyMatchedError s = CycloneStore() cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A") s.add(BatchRecord835( id="b-1", kind="835", input_filename="era.txt", parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc), result=_make_835_result([cp]), )) from cyclone.db import Claim with db.SessionLocal() as sess: sess.add(Claim(id="CLM-X", batch_id="b-1", patient_control_number="PCN-A", charge_amount=100, state="paid", matched_remittance_id="CLP-1:b1xxxxxx")) sess.commit() with pytest.raises(AlreadyMatchedError): s.manual_match("CLM-X", "CLP-1:b1xxxxxx")Use this cleaner version.
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v
Expected: failures on list_unmatched, manual_match.
- Step 3: Verify store.list_unmatched / manual_match / manual_unmatch are already implemented
These methods were added in Task 9's store rewrite (search for def list_unmatched and def manual_match in backend/src/cyclone/store.py). They should now pass the tests.
If a test fails:
-
list_unmatchedreturns the wrong shape → checkiter_claims/iter_remittancesmappers -
manual_matchraises the wrong exception → checkAlreadyMatchedError -
manual_matchdoesn't update Claim state → check the manual_match body -
Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py -v
Expected: 3 tests pass (the clean version of test_manual_match_conflict_raises plus the previous 2).
- Step 5: Commit
git add backend/tests/test_store_reconcile.py
git commit -m "test(backend): cover list_unmatched + manual_match + conflict path"
Phase 5 — API additions
Task 13: Wire db.init() into __main__.py startup
Files:
-
Modify:
backend/src/cyclone/__main__.py -
Step 1: Read current
__main__.pyand find theserveentry point
The serve function calls uvicorn.run(...). Before that, call db.init_db().
- Step 2: Add the import + call
from cyclone import db
def serve() -> None:
db.init_db() # creates engine, runs migrations
# ... existing uvicorn.run(...) call
- Step 3: Smoke test startup
Run: cd backend && CYCLONE_DB_URL=sqlite:///$HOME/.local/share/cyclone/smoke.db rm -f ~/.local/share/cyclone/smoke.db; .venv/bin/python -m cyclone serve &
Wait 2 seconds, then:
Run: sqlite3 ~/.local/share/cyclone/smoke.db ".tables"
Expected: prints activity_events batches cas_adjustments claims matches remittances.
Kill the server: pkill -f "python -m cyclone serve" || true
- Step 4: Commit
git add backend/src/cyclone/__main__.py
git commit -m "feat(backend): call db.init_db() before uvicorn starts"
Task 14: API endpoint GET /api/reconciliation/unmatched
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_gets.py -
Step 1: Add failing tests
Append to backend/tests/test_api_gets.py:
def test_get_reconciliation_unmatched_empty(client):
r = client.get("/api/reconciliation/unmatched")
assert r.status_code == 200
assert r.json() == {"claims": [], "remittances": []}
def test_get_reconciliation_unmatched_returns_orphans(client, monkeypatch):
"""A Claim with no match + a Remit with no claim should both surface."""
# Use the same client fixture; populate via the store directly.
from cyclone.store import CycloneStore, BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
ClaimOutput, Envelope, ParseResult, ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimPayment, ParseResult835, Envelope as Env835,
FinancialInfo, ReassociationTrace, Payer835, Payee835, BatchSummary,
)
from datetime import datetime, timezone
co = ClaimOutput(
claim_id="CLM-1",
billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
"address": None, "city": None, "state": None, "zip": None,
"phone": None, "tax_id": None},
subscriber={"member_id": "M1", "first_name": "J", "last_name": "D",
"dob": None, "gender": None, "address": None, "city": None,
"state": None, "zip": None},
payer={"id": "P1", "name": "X", "type": None, "address": None,
"city": None, "state": None, "zip": None},
diagnoses=[], service_lines=[],
claim={"total_charge": 100.0, "place_of_service": "11",
"frequency_code": "1", "prior_auth": None,
"provider_signature": None, "assignment": None,
"benefits_assigned": None, "release_of_info": None},
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
pr837 = ParseResult(
envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="837",
transaction_date="2026-06-19", transaction_time=None),
billing_provider=co.billing_provider, claims=[co],
summary={"total_claims": 1, "passed": 1, "failed": 0,
"started_at": "2026-06-19T12:00:00Z",
"finished_at": "2026-06-19T12:00:01Z",
"duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
)
CycloneStore().add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=pr837,
))
CycloneStore().add(BatchRecord835(
id="b-835", kind="835", input_filename="e.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Env835(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="835",
transaction_date="2026-06-19", transaction_time=None),
financial_info=FinancialInfo(credit_debit="C", payment_amount=0.0,
credit_debit_indicator=None,
payment_method=None, date=None),
trace=ReassociationTrace(trace_type_code=None, trace_number=None,
originating_company_id=None,
originating_company_qual=None),
payer=Payer835(name="X", id="SKCO0"),
payee=Payee835(name="Y", npi="1234567890"),
claims=[ClaimPayment(payer_claim_control_number="PCN-OTHER",
status_code="1", total_charge=50.0, total_paid=50.0)],
summary=BatchSummary(total_claims=1, paid=0, denied=0,
reversed=0, total_paid=0.0, total_adjustment=0.0),
),
))
r = client.get("/api/reconciliation/unmatched")
assert r.status_code == 200
body = r.json()
assert len(body["claims"]) == 1
assert body["claims"][0]["id"] == "CLM-1"
assert len(body["remittances"]) == 1
assert body["remittances"][0]["payerClaimControlNumber"] == "PCN-OTHER"
Test fixture note: the existing
test_api_gets.pyhas aclientfixture that usesfastapi.testclient.TestClient(app). It also sets upinit_dbsomewhere — check the file. If not, you need to add@pytest.fixture(autouse=True)that callsdb.init_db()with a tmp_path URL. Adapt the test above to use the existing fixtures.
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "reconciliation_unmatched"
Expected: 404 (route not defined).
- Step 3: Add the route to
api.py
In backend/src/cyclone/api.py, add to the imports:
from cyclone.store import AlreadyMatchedError, InvalidStateError
Add a new route:
@app.get("/api/reconciliation/unmatched")
def get_reconciliation_unmatched() -> dict:
"""Returns unmatched Claims (left) and unmatched Remittances (right)."""
return store.list_unmatched(kind="both")
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "reconciliation"
Expected: 2 tests pass.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): GET /api/reconciliation/unmatched"
Task 15: API endpoints POST /api/reconciliation/match + /unmatch
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_gets.py -
Step 1: Add failing tests
Append to backend/tests/test_api_gets.py:
def test_post_match_happy_path(client):
"""Match an unmatched claim + remit; both removed from unmatched bucket."""
from cyclone.store import CycloneStore, BatchRecord837
from cyclone.parsers.models import (
ClaimOutput, Envelope, ParseResult, ValidationReport,
)
from datetime import datetime, timezone
co = ClaimOutput(
claim_id="CLM-1",
billing_provider={"npi": "1234567890", "name": "T", "taxonomy": None,
"address": None, "city": None, "state": None, "zip": None,
"phone": None, "tax_id": None},
subscriber={"member_id": "M1", "first_name": "J", "last_name": "D",
"dob": None, "gender": None, "address": None, "city": None,
"state": None, "zip": None},
payer={"id": "P1", "name": "X", "type": None, "address": None,
"city": None, "state": None, "zip": None},
diagnoses=[], service_lines=[],
claim={"total_charge": 100.0, "place_of_service": "11",
"frequency_code": "1", "prior_auth": None,
"provider_signature": None, "assignment": None,
"benefits_assigned": None, "release_of_info": None},
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
pr837 = ParseResult(
envelope=Envelope(sender_id="S", sender_qualifier="ZZ", receiver_id="R",
receiver_qualifier="ZZ", control_number="0001",
usage_indicator="P", transaction_set="837",
transaction_date="2026-06-19", transaction_time=None),
billing_provider=co.billing_provider, claims=[co],
summary={"total_claims": 1, "passed": 1, "failed": 0,
"started_at": "2026-06-19T12:00:00Z",
"finished_at": "2026-06-19T12:00:01Z",
"duration_ms": 1000, "payer": "co_medicaid", "errors": 0},
)
CycloneStore().add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=pr837,
))
# Use the test's already-set-up orphaned remit from the previous test or
# create one inline. Simplest: pull from /api/reconciliation/unmatched.
out = client.get("/api/reconciliation/unmatched").json()
if not out["remittances"]:
# Create an orphan via direct DB if needed (depends on test order).
from cyclone.db import Remittance, Batch
from datetime import datetime as _dt
with __import__("cyclone").db.SessionLocal() as s:
s.add(Batch(id="b-835", kind="835", input_filename="e",
parsed_at=_dt.now(timezone.utc)))
s.add(Remittance(id="CLP-1:b835xxxx", batch_id="b-835",
payer_claim_control_number="CLM-1",
status_code="1", total_charge=100.0,
total_paid=100.0,
received_at=_dt.now(timezone.utc)))
s.commit()
out = client.get("/api/reconciliation/unmatched").json()
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
r = client.post("/api/reconciliation/match",
json={"claim_id": claim_id, "remit_id": remit_id})
assert r.status_code == 200
body = r.json()
assert body["claim"]["state"] == "paid"
assert body["match"]["strategy"] == "manual"
def test_post_match_conflict_returns_409(client):
"""Matching an already-matched claim returns 409."""
out = client.get("/api/reconciliation/unmatched").json()
if not out["claims"] or not out["remittances"]:
pytest.skip("no unmatched rows available")
cid = out["claims"][0]["id"]
rid = out["remittances"][0]["id"]
r1 = client.post("/api/reconciliation/match", json={"claim_id": cid, "remit_id": rid})
assert r1.status_code == 200
# Second match should 409.
r2 = client.post("/api/reconciliation/match", json={"claim_id": cid, "remit_id": rid})
assert r2.status_code == 409
assert r2.json()["error"] == "already_matched"
def test_post_unmatch_removes_match(client):
"""Unmatch reverses a manual pair and returns the claim to submitted."""
# Setup: create a manual pair.
from cyclone.db import Batch, Claim, Remittance
from datetime import datetime, timezone
from decimal import Decimal
from cyclone import db
with db.SessionLocal() as s:
s.add(Batch(id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc)))
s.add(Claim(id="CLM-X", batch_id="b1", patient_control_number="CLM-X",
charge_amount=Decimal("100"), state="paid",
matched_remittance_id="CLP-Y:b1xxxxxx"))
s.add(Remittance(id="CLP-Y:b1xxxxxx", batch_id="b1",
payer_claim_control_number="CLM-X",
status_code="1", total_charge=Decimal("100"),
total_paid=Decimal("100"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc)))
s.commit()
r = client.post("/api/reconciliation/unmatch", json={"claim_id": "CLM-X"})
assert r.status_code == 200
assert r.json()["claim"]["state"] == "submitted"
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "match or unmatch"
Expected: 404 (route not defined).
- Step 3: Add the two POST routes
In backend/src/cyclone/api.py:
@app.post("/api/reconciliation/match")
def post_reconciliation_match(body: dict) -> dict:
"""Manually pair a Claim with a Remittance."""
claim_id = body.get("claim_id")
remit_id = body.get("remit_id")
if not claim_id or not remit_id:
raise HTTPException(status_code=400, detail="claim_id and remit_id required")
try:
return store.manual_match(claim_id, remit_id)
except AlreadyMatchedError as e:
raise HTTPException(
status_code=409,
detail={"error": "already_matched", "message": str(e)},
)
except InvalidStateError as e:
raise HTTPException(
status_code=409,
detail={"error": "invalid_state", "current_state": e.current_state,
"activity_kind": e.activity_kind},
)
except KeyError:
raise HTTPException(status_code=404, detail="claim_or_remit_not_found")
@app.post("/api/reconciliation/unmatch")
def post_reconciliation_unmatch(body: dict) -> dict:
"""Remove a manual match; reset Claim to submitted."""
claim_id = body.get("claim_id")
if not claim_id:
raise HTTPException(status_code=400, detail="claim_id required")
try:
return store.manual_unmatch(claim_id)
except KeyError:
raise HTTPException(status_code=404, detail="no_current_match")
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v
Expected: 5 tests pass (2 from Task 14 + 3 from this task).
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): POST /api/reconciliation/{match,unmatch}"
Task 16: Update parse-835 response to include reconciliation summary
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_parse_persists.py -
Step 1: Add failing test
Append to backend/tests/test_api_parse_persists.py:
def test_parse_835_response_includes_reconciliation_summary(client, tmp_path):
"""A successful 835 parse returns matched/unmatched counts."""
fixture = (Path(__file__).parent / "fixtures" / "co_medicaid_835.txt").read_text()
p = tmp_path / "era.txt"
p.write_text(fixture)
with open(p, "rb") as f:
r = client.post(
"/api/parse-835",
files={"file": ("era.txt", f, "text/plain")},
headers={"Accept": "application/json"},
)
assert r.status_code == 200
body = r.json()
assert "reconciliation" in body
rec = body["reconciliation"]
assert "matched" in rec
assert "unmatched_claims" in rec
assert "unmatched_remittances" in rec
assert "skipped" in rec
- Step 2: Run test to verify it fails
Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v -k "reconciliation_summary"
Expected: KeyError on body["reconciliation"].
- Step 3: Update
parse_835_routeto return reconciliation counts
In backend/src/cyclone/api.py, locate the existing parse_835_route function. After the store.add(...) call, look up the latest ReconcileResult for this batch_id and include it in the response.
Replace the existing parse-835 route body with:
@app.post("/api/parse-835")
def parse_835_route(
request: Request,
file: UploadFile = File(...),
payer: str = Query("co_medicaid_835"),
) -> JSONResponse:
raw = file.file.read()
text = raw.decode("utf-8", errors="replace")
cfg = _resolve_payer_835(payer)
try:
result = parse_835(text, cfg)
except CycloneParseError as e:
raise HTTPException(status_code=400, detail={"error": "parse_failed", "message": str(e)})
validated = validate_835(result, cfg)
if validated and not validated.passed:
raise HTTPException(
status_code=422,
detail={"error": "validation_failed", "issues": [i.model_dump() for i in validated.errors]},
)
batch_id = uuid.uuid4().hex
record = BatchRecord835(
id=batch_id, kind="835", input_filename=file.filename or "upload.txt",
parsed_at=utcnow(), result=result,
)
store.add(record)
# Reconciliation runs inside store.add(); fetch the resulting counts.
from cyclone import reconcile as _recon # noqa: WPS433
with db.SessionLocal() as s2:
from sqlalchemy import select, func
from cyclone.db import Match, Remittance
matched = s2.execute(
select(func.count(Match.id)).where(Match.remittance_id.in_(
select(Remittance.id).where(Remittance.batch_id == batch_id)
))
).scalar_one()
# The store.add path writes the activity event for "reconcile"; just
# count matched Remittances for the response summary.
from cyclone.db import ActivityEvent
evt = s2.execute(
select(ActivityEvent.payload_json)
.where(ActivityEvent.batch_id == batch_id, ActivityEvent.kind == "reconcile")
.order_by(ActivityEvent.ts.desc()).limit(1)
).scalar_one_or_none()
skipped = 0
# Pull unmatched counts via list_unmatched (cheap; small result set).
unmatched = store.list_unmatched(kind="both")
body = {
"id": batch_id,
"kind": "835",
"input_filename": record.input_filename,
"parsed_at": record.parsed_at.isoformat().replace("+00:00", "Z"),
"totals": result.summary.model_dump(),
"validation": validated.model_dump() if validated else None,
"reconciliation": {
"matched": matched,
"unmatched_claims": len(unmatched["claims"]),
"unmatched_remittances": len(unmatched["remittances"]),
"skipped": skipped,
},
}
return JSONResponse(body)
Note: The
matchedcount is computed via SQL on thematchestable for Remittances in this batch.unmatchedis computed vialist_unmatched. This is a read-after-write, so within the same process the data is consistent. Theskippedcount is taken from the most recentreconcileactivity event payload (or 0 if no skipped happened) — the activity event payload carries{"count": N, ...}style data. Adjust if needed.
- Step 4: Run test to verify it passes
Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v -k "reconciliation_summary"
Expected: 1 test passes.
- Step 5: Run the full test suite to confirm nothing regressed
Run: cd backend && .venv/bin/pytest -q
Expected: ≥ 178 (existing) + 22 (reconcile pure) + 4 (db_migrate) + 1 (CAS agg) + 3 (store_reconcile) + ~5 (api) = ~213 tests pass.
- Step 6: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
git commit -m "feat(backend): parse-835 response includes reconciliation summary"
Task 17: Remove the adjustmentAmount: 0.0 TODO marker + verify mappers
Files:
-
Modify:
backend/src/cyclone/store.py(already done —_remittance_835_rowno longer hardcodes it;reconcile.runupdates it from CAS) -
Verify:
backend/tests/test_store_reconcile.py(the CAS aggregation test from Task 11) -
Step 1: Verify
adjustmentAmount: 0.0TODO is gone
Run: cd backend && grep -n "adjustmentAmount: 0.0" src/cyclone/store.py || echo "GONE"
Expected: GONE.
- Step 2: Verify
store.iter_remittances()returns real adjustmentAmount
Run: cd backend && .venv/bin/pytest tests/test_store_reconcile.py::test_add_835_aggregates_cas_into_adjustment_amount -v
Expected: pass.
- Step 3: (No commit — this is a verification task)
If both steps succeed, this task is complete. Move on.
Phase 6 — Frontend
Task 18: Add ClaimState enum + Match types in src/types/index.ts
Files:
-
Modify:
src/types/index.ts -
Step 1: Add the new types
Read the current src/types/index.ts to see where to add. At the bottom (or in an appropriate section), add:
export type ClaimState =
| "submitted"
| "received"
| "paid"
| "partial"
| "denied"
| "reconciled"
| "reversed";
export const CLAIM_STATES: ClaimState[] = [
"submitted",
"received",
"paid",
"partial",
"denied",
"reconciled",
"reversed",
];
export interface Match {
id: number;
claim_id: string;
remittance_id: string;
strategy: "auto" | "manual";
matched_at: string;
prior_claim_state?: ClaimState | null;
is_reversal: boolean;
}
export interface UnmatchedClaim {
id: string;
patientControlNumber: string;
serviceDate: string | null;
chargeAmount: number;
providerNpi: string | null;
payerId: string | null;
state: ClaimState;
}
export interface UnmatchedRemittance {
id: string;
payerClaimControlNumber: string;
statusCode: string;
totalCharge: number;
totalPaid: number;
adjustmentAmount: number;
serviceDate: string | null;
isReversal: boolean;
batchId: string;
}
export interface UnmatchedResponse {
claims: UnmatchedClaim[];
remittances: UnmatchedRemittance[];
}
export interface MatchResponse {
claim: UnmatchedClaim;
match: { id: number; strategy: "auto" | "manual" };
}
- Step 2: Verify typecheck passes
Run: npm run typecheck
Expected: clean (no errors).
- Step 3: Commit
git add src/types/index.ts
git commit -m "feat(frontend): add ClaimState enum + Match/Unmatched types"
Task 19: Add api.listUnmatched, api.matchRemit, api.unmatchClaim to src/lib/api.ts
Files:
-
Modify:
src/lib/api.ts -
Step 1: Read current
src/lib/api.tsto see the existing pattern
Look at how listClaims etc. are implemented. Match the style.
- Step 2: Add the 3 new methods
After the existing listActivity method, add:
async listUnmatched(): Promise<UnmatchedResponse> {
return this.request<UnmatchedResponse>("/api/reconciliation/unmatched");
},
async matchRemit(claimId: string, remitId: string): Promise<MatchResponse> {
const r = await fetch(`${this.baseUrl}/api/reconciliation/match`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId, remit_id: remitId }),
});
if (!r.ok) throw new ApiError(r.status, await r.text());
return r.json();
},
async unmatchClaim(claimId: string): Promise<{ claim: UnmatchedClaim }> {
const r = await fetch(`${this.baseUrl}/api/reconciliation/unmatch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId }),
});
if (!r.ok) throw new ApiError(r.status, await r.text());
return r.json();
},
Add ApiError class if it doesn't exist:
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
Update the imports at the top:
import type { ..., UnmatchedResponse, MatchResponse } from "@/types";
- Step 3: Verify typecheck passes
Run: npm run typecheck
Expected: clean.
- Step 4: Commit
git add src/lib/api.ts
git commit -m "feat(frontend): add listUnmatched, matchRemit, unmatchClaim to api"
Task 20: Add useReconciliation hook + tests
Files:
-
Create:
src/hooks/useReconciliation.ts -
Create:
src/hooks/useReconciliation.test.ts -
Step 1: Implement the hook
src/hooks/useReconciliation.ts:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
export function useReconciliation() {
const qc = useQueryClient();
const unmatched = useQuery({
queryKey: ["reconciliation", "unmatched"],
queryFn: () => api.listUnmatched(),
refetchInterval: 30_000,
});
const match = useMutation({
mutationFn: (args: { claimId: string; remitId: string }) =>
api.matchRemit(args.claimId, args.remitId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["reconciliation"] });
qc.invalidateQueries({ queryKey: ["claims"] });
qc.invalidateQueries({ queryKey: ["remittances"] });
qc.invalidateQueries({ queryKey: ["activity"] });
},
});
const unmatch = useMutation({
mutationFn: (args: { claimId: string }) => api.unmatchClaim(args.claimId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["reconciliation"] });
qc.invalidateQueries({ queryKey: ["claims"] });
qc.invalidateQueries({ queryKey: ["remittances"] });
qc.invalidateQueries({ queryKey: ["activity"] });
},
});
return { unmatched, match, unmatch };
}
- Step 2: Write the tests
src/hooks/useReconciliation.test.ts:
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useReconciliation } from "./useReconciliation";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
listUnmatched: vi.fn(),
matchRemit: vi.fn(),
unmatchClaim: vi.fn(),
},
}));
function wrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children);
}
describe("useReconciliation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns unmatched query + match + unmatch mutations", async () => {
(api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
const { result } = renderHook(() => useReconciliation(), { wrapper: wrapper() });
await waitFor(() => expect(result.current.unmatched.isSuccess).toBe(true));
expect(result.current.unmatched.data).toEqual({ claims: [], remittances: [] });
expect(typeof result.current.match.mutate).toBe("function");
expect(typeof result.current.unmatch.mutate).toBe("function");
});
it("match.onSuccess invalidates reconciliation + claims + remittances + activity", async () => {
(api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
(api.matchRemit as any).mockResolvedValue({
claim: { id: "c1" }, match: { id: 1, strategy: "manual" },
});
const { result } = renderHook(() => useReconciliation(), { wrapper: wrapper() });
result.current.match.mutate({ claimId: "c1", remitId: "r1" });
await waitFor(() => expect(result.current.match.isSuccess).toBe(true));
});
});
- Step 3: Run tests
Run: npm test -- useReconciliation
Expected: 2 tests pass.
- Step 4: Commit
git add src/hooks/useReconciliation.ts src/hooks/useReconciliation.test.ts
git commit -m "feat(frontend): useReconciliation hook with react-query invalidation cascade"
Task 21: claim-state-badge primitive (7-state, same palette)
Files:
-
Create:
src/components/ui/claim-state-badge.tsx -
Step 1: Implement
src/components/ui/claim-state-badge.tsx:
import { Badge } from "@/components/ui/badge";
import type { ClaimState } from "@/types";
import { cn } from "@/lib/utils";
import {
CircleDashed, Send, Inbox, CheckCircle2, AlertTriangle,
Ban, CheckCheck, RotateCcw,
} from "lucide-react";
const STATE_CONFIG: Record<ClaimState, {
label: string;
icon: React.ComponentType<{ className?: string; strokeWidth?: number }>;
className: string;
}> = {
submitted: { label: "Submitted", icon: Send, className: "bg-accent/15 text-accent border-accent/30" },
received: { label: "Received", icon: Inbox, className: "bg-accent/10 text-accent/80 border-accent/20" },
paid: { label: "Paid", icon: CheckCircle2,className: "bg-success/15 text-success border-success/30" },
partial: { label: "Partial", icon: AlertTriangle,className: "bg-warning/15 text-warning border-warning/30" },
denied: { label: "Denied", icon: Ban, className: "bg-destructive/15 text-destructive border-destructive/30" },
reconciled: { label: "Reconciled", icon: CheckCheck, className: "bg-success/15 text-success border-success/30" },
reversed: { label: "Reversed", icon: RotateCcw, className: "bg-warning/15 text-warning border-warning/30" },
};
export function ClaimStateBadge({ state, className }: { state: ClaimState; className?: string }) {
const cfg = STATE_CONFIG[state];
const Icon = cfg.icon;
return (
<Badge
variant="outline"
className={cn("gap-1.5 font-mono uppercase tracking-wider text-[10px]",
cfg.className, className)}
>
<Icon className="h-3 w-3" strokeWidth={1.75} />
{cfg.label}
</Badge>
);
}
Note:
bg-success/15andtext-successassume these tokens exist in your Tailwind config from sub-project 1. If they don't, usebg-emerald-500/15andtext-emerald-400(or whatever the existing palette tokens are). Checksrc/index.cssortailwind.config.jsfor the available tokens.
- Step 2: Verify typecheck + build
Run: npm run typecheck && npm run build
Expected: clean.
- Step 3: Commit
git add src/components/ui/claim-state-badge.tsx
git commit -m "feat(frontend): ClaimStateBadge primitive (7 states, same palette)"
Task 22: Refactor StatusBadge.tsx to delegate to ClaimStateBadge + update Claims page
Files:
-
Modify:
src/components/StatusBadge.tsx -
Modify:
src/pages/Claims.tsx -
Step 1: Read the current
StatusBadge.tsxto see how it maps the legacystatusfield
The existing component takes a status string ("submitted", "denied", etc.) and renders a colored badge. Replace the body to delegate to ClaimStateBadge when the value is a known ClaimState.
Modify src/components/StatusBadge.tsx:
import { ClaimStateBadge } from "@/components/ui/claim-state-badge";
import { Badge } from "@/components/ui/badge";
import { CLAIM_STATES, type ClaimState } from "@/types";
import { cn } from "@/lib/utils";
const LEGACY_COLOR: Record<string, string> = {
draft: "bg-muted text-muted-foreground border-border",
pending: "bg-warning/15 text-warning border-warning/30",
accepted: "bg-success/15 text-success border-success/30",
posted: "bg-warning/15 text-warning border-warning/30",
};
export function StatusBadge({ status, className }: { status: string; className?: string }) {
if (CLAIM_STATES.includes(status as ClaimState)) {
return <ClaimStateBadge state={status as ClaimState} className={className} />;
}
// Legacy / unmapped status (e.g., "draft" from old data).
return (
<Badge variant="outline" className={cn("uppercase tracking-wider text-[10px]",
LEGACY_COLOR[status] ?? "bg-muted text-muted-foreground border-border",
className)}>
{status}
</Badge>
);
}
- Step 2: Update
Claims.tsxto usestatefrom the API
In src/pages/Claims.tsx, find where <StatusBadge> is rendered. Pass claim.state directly (instead of inferring a status string). Confirm the hook returns state as one of the 7 values.
- Step 3: Verify typecheck + build
Run: npm run typecheck && npm run build
Expected: clean.
- Step 4: Commit
git add src/components/StatusBadge.tsx src/pages/Claims.tsx
git commit -m "refactor(frontend): StatusBadge delegates to ClaimStateBadge; Claims uses API state"
Task 23: Update Remittances.tsx to render real adjustmentAmount
Files:
-
Modify:
src/pages/Remittances.tsx -
Step 1: Read the current page and find where amounts are rendered
-
Step 2: Add an "Adjustment" column if not present
Find the columns array / table header. Add an Adjustment column that renders remit.adjustmentAmount formatted with the existing formatCurrency helper.
- Step 3: Verify build
Run: npm run build
Expected: clean.
- Step 4: Commit
git add src/pages/Remittances.tsx
git commit -m "feat(frontend): render real adjustmentAmount on Remittances page"
Task 24: Reconciliation page
Files:
-
Create:
src/pages/Reconciliation.tsx -
Create:
src/pages/Reconciliation.test.tsx -
Modify:
src/App.tsx(add route) -
Modify:
src/lib/api.test.ts(mock if needed) -
Step 1: Implement the page
src/pages/Reconciliation.tsx:
import { useState } from "react";
import { useReconciliation } from "@/hooks/useReconciliation";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Button } from "@/components/ui/button";
import { useToast } from "sonner";
import { ApiError } from "@/lib/api";
import { CircleDashed, GitMerge } from "lucide-react";
import { cn } from "@/lib/utils";
export function ReconciliationPage() {
const { unmatched, match, unmatch } = useReconciliation();
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
const toast = useToast();
if (unmatched.isLoading) {
return (
<div className="grid grid-cols-2 gap-4">
<Skeleton variant="card" /><Skeleton variant="card" />
</div>
);
}
if (unmatched.isError) {
return <ErrorState error={unmatched.error} onRetry={() => unmatched.refetch()} />;
}
const { claims, remittances } = unmatched.data ?? { claims: [], remittances: [] };
const empty = claims.length === 0 && remittances.length === 0;
if (empty) {
return (
<EmptyState
eyebrow="Reconciliation · nothing pending"
message="Every claim and remittance is paired."
/>
);
}
const handleMatch = async () => {
if (!selectedClaim || !selectedRemit) return;
try {
await match.mutateAsync({ claimId: selectedClaim, remitId: selectedRemit });
toast.success(`Matched ${selectedClaim} ↔ ${selectedRemit}`);
setSelectedClaim(null);
setSelectedRemit(null);
} catch (e) {
const msg = e instanceof ApiError && e.status === 409
? "Already matched — view the existing match."
: (e as Error).message;
toast.error(msg);
}
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Claims column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched claims ({claims.length})
</h2>
{claims.map((c) => (
<button
key={c.id}
onClick={() => setSelectedClaim(c.id)}
className={cn(
"w-full text-left p-3 rounded-md border transition-colors",
selectedClaim === c.id
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border",
)}
>
<div className="font-mono text-sm">{c.patientControlNumber}</div>
<div className="text-xs text-muted-foreground">
{c.serviceDate ?? "—"} · ${c.chargeAmount.toFixed(2)} · NPI {c.providerNpi ?? "—"}
</div>
</button>
))}
</div>
{/* Remits column */}
<div className="surface rounded-xl border border-border/60 p-4 space-y-2">
<h2 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Unmatched remits ({remittances.length})
</h2>
{remittances.map((r) => (
<button
key={r.id}
onClick={() => setSelectedRemit(r.id)}
className={cn(
"w-full text-left p-3 rounded-md border transition-colors",
selectedRemit === r.id
? "border-accent bg-accent/5"
: "border-border/60 hover:border-border",
)}
>
<div className="font-mono text-sm flex items-center gap-2">
{r.payerClaimControlNumber}
{r.isReversal && (
<span className="text-[10px] text-warning uppercase tracking-wider">
Reversal
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
Status {r.statusCode} · ${r.totalPaid.toFixed(2)} paid · ${r.adjustmentAmount.toFixed(2)} adj
</div>
</button>
))}
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleMatch}
disabled={!selectedClaim || !selectedRemit || match.isPending}
>
<GitMerge className="h-3.5 w-3.5 mr-1.5" />
Match selected
</Button>
<Button
variant="outline"
onClick={() => { setSelectedClaim(null); setSelectedRemit(null); }}
>
Clear selection
</Button>
</div>
</div>
);
}
Note:
useToastmay already be imported fromsonnerin the existing codebase astoast. Adjust import per project conventions. Checksrc/components/ui/toast.tsxor existing pages.
- Step 2: Add the route in
src/App.tsx
import { ReconciliationPage } from "@/pages/Reconciliation";
// inside <Routes>:
<Route path="/reconciliation" element={<ReconciliationPage />} />
- Step 3: Write the test
src/pages/Reconciliation.test.tsx:
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { ReconciliationPage } from "./Reconciliation";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
listUnmatched: vi.fn(),
matchRemit: vi.fn(),
unmatchClaim: vi.fn(),
},
ApiError: class ApiError extends Error { constructor(public status: number, m: string) { super(m); } },
}));
function wrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) =>
React.createElement(QueryClientProvider, { client: qc }, children);
}
describe("ReconciliationPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders both columns with unmatched rows", async () => {
(api.listUnmatched as any).mockResolvedValue({
claims: [{
id: "CLM-1", patientControlNumber: "PCN-A", serviceDate: "2026-06-01",
chargeAmount: 100, providerNpi: "1234567890", payerId: "P1", state: "submitted",
}],
remittances: [{
id: "CLP-1:b1", payerClaimControlNumber: "PCN-A", statusCode: "1",
totalCharge: 100, totalPaid: 100, adjustmentAmount: 0,
serviceDate: "2026-06-01", isReversal: false, batchId: "b1",
}],
});
render(<ReconciliationPage />, { wrapper: wrapper() });
await waitFor(() => expect(screen.getByText("CLM-1")).toBeInTheDocument());
expect(screen.getByText("PCN-A")).toBeInTheDocument();
});
it("renders empty state when nothing is unmatched", async () => {
(api.listUnmatched as any).mockResolvedValue({ claims: [], remittances: [] });
render(<ReconciliationPage />, { wrapper: wrapper() });
await waitFor(() =>
expect(screen.getByText(/nothing pending/i)).toBeInTheDocument()
);
});
});
- Step 4: Verify typecheck + tests pass
Run: npm run typecheck && npm test -- Reconciliation
Expected: clean + 2 tests pass.
- Step 5: Commit
git add src/pages/Reconciliation.tsx src/pages/Reconciliation.test.tsx src/App.tsx
git commit -m "feat(frontend): /reconciliation page with two-column matching UI"
Task 25: Sidebar nav entry with badge count
Files:
-
Modify:
src/components/Sidebar.tsx -
Step 1: Read the current
Sidebar.tsxto see the existing NavItem pattern -
Step 2: Add the Reconciliation nav entry
After the existing <NavItem to="/claims"> (or wherever appropriate), add:
import { GitMerge } from "lucide-react";
import { useReconciliation } from "@/hooks/useReconciliation";
// Inside Sidebar component:
const { unmatched } = useReconciliation();
const unmatchedCount = (unmatched.data?.claims.length ?? 0) +
(unmatched.data?.remittances.length ?? 0);
// In the nav list:
<NavItem to="/reconciliation" icon={<GitMerge className="h-4 w-4" strokeWidth={1.5} />}>
Reconciliation
{unmatchedCount > 0 && (
<span className="ml-auto text-[10px] font-mono tabular-nums text-warning">
{unmatchedCount > 99 ? "99+" : unmatchedCount}
</span>
)}
</NavItem>
- Step 3: Verify build
Run: npm run build
Expected: clean.
- Step 4: Commit
git add src/components/Sidebar.tsx
git commit -m "feat(frontend): sidebar Reconciliation nav entry with badge count"
Task 26: Vitest config — exclude .worktrees/
Files:
-
Modify:
vitest.config.ts -
Step 1: Add the exclude
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: [
"**/node_modules/**",
"**/dist/**",
".worktrees/**", // ← NEW
],
},
});
- Step 2: Run tests; verify count is 7 (not 10+)
Run: npm test
Expected: Test Files 1 passed (1) and Tests 7 passed (7). The worktree's tests no longer get picked up.
- Step 3: Commit
git add vitest.config.ts
git commit -m "chore(frontend): exclude .worktrees from vitest discovery"
Phase 7 — Docs + housekeeping + smoke
Task 27: Update root README — DB path, CYCLONE_DB_URL, backup recipe
Files:
-
Modify:
README.md -
Step 1: Read current README to see existing structure
The README was rewritten in sub-project 1. Find the "Install" and "Dev" sections.
- Step 2: Add a "Persistence" subsection
After the "Test" section (or as a new top-level section), add:
## Persistence
Parsed batches, claims, remittances, matches, and activity events are
stored in a SQLite file at `~/.local/share/cyclone/cyclone.db` by
default. The directory is auto-created on first run.
To use a different location, set `CYCLONE_DB_URL`:
```bash
export CYCLONE_DB_URL=sqlite:///path/to/cyclone.db
# or
export CYCLONE_DB_URL=postgresql://user:pass@host:5432/cyclone
Backup
sqlite3 ~/.local/share/cyclone/cyclone.db ".backup /path/to/backup.db"
This is safe to run while the backend is running (uses SQLite's online backup API).
- [ ] **Step 3: Update the Roadmap section to reflect sub-project 2 completion**
```markdown
## Roadmap
Sub-project 2 (DB + reconciliation) is **shipped**. The other two:
- **Sub-project 3 — More 837P/835 features.** Additional 837P validation
rules (REF*G1 enforcement, BHT06), 835 CAS deep-parsing, 999 ACK, and
270/271 eligibility.
- **Sub-project 4 — Frontend features.** Per-claim detail drawer, batch
diff view, real-time streaming of NDJSON GET responses, advanced
filters (date range, multi-status, saved filter sets), keyboard-driven
navigation.
- Step 4: Commit
git add README.md
git commit -m "docs: document CYCLONE_DB_URL, backup recipe, sub-project 2 completion"
Task 28: Tick off the 107 unchecked boxes in the production-readiness plan
Files:
-
Modify:
docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md -
Step 1: Confirm the housekeeping commit is appropriate
All sub-project 1 work is merged to main; all the boxes in that plan
correspond to work that landed in real commits. Ticking them off is a
cosmetic update for future archaeology.
- Step 2: Bulk-replace the checkboxes
Run:
sed -i '' 's/- \[ \]/- [x]/g' docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md
Verify: grep -c "\- \[x\]" docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md
Expected: ~107 matches (was 0).
- Step 3: Commit
git add docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md
git commit -m "docs: tick off completed boxes in sub-project 1 plan (housekeeping)"
Task 29: Smoke test — full end-to-end
Files: none (manual procedure; recorded in the commit message + acceptance checklist)
- Step 1: Clean DB
rm -f ~/.local/share/cyclone/cyclone.db
- Step 2: Start backend
cd backend && .venv/bin/python -m cyclone serve
In another terminal, verify the DB was created:
sqlite3 ~/.local/share/cyclone/cyclone.db ".tables"
Expected: activity_events batches cas_adjustments claims matches remittances.
- Step 3: Start frontend
cd .. && npm run dev
- Step 4: Upload CO 837P
Open http://localhost:5173/upload. Drop backend/tests/fixtures/co_medicaid_837p.txt.
Expected:
-
Toast "Parsed 2 claims".
-
Claims page shows 2 rows with state
submitted. -
Step 5: Upload CO 835
Drop backend/tests/fixtures/co_medicaid_835.txt.
Expected:
-
Toast includes "matched: N".
-
Claims page: matched rows show
paidorreconciled(depending on fixture content). -
Any orphan remits appear in
/reconciliationpage. -
Step 6: Visit /reconciliation
Verify:
-
Unmatched claims column populates.
-
Unmatched remits column populates.
-
Click a claim → it highlights.
-
Click a remit → it highlights.
-
Click "Match selected" → both rows disappear; toast "Matched X ↔ Y".
-
Step 7: Restart persistence test
# Ctrl-C the backend
cd backend && .venv/bin/python -m cyclone serve &
Reload the frontend. All data still present.
- Step 8: Full test suite
cd backend && .venv/bin/pytest -q
Expected: ~213 tests pass.
npm run typecheck && npm run build && npm test
Expected: clean, clean, 7 tests pass (no .worktrees/ duplicates).
- Step 9: Manual commit (no code changes) — record the smoke pass
git commit --allow-empty -m "smoke: end-to-end DB + reconciliation flow passes (sub-project 2)"
Task 30: Final cleanup — merge worktree to main
This task runs after smoke. It's the merge step, not a coding task.
- Step 1: Verify the worktree is clean
cd .worktrees/db-reconciliation && git status
Expected: nothing to commit, on branch db-reconciliation.
- Step 2: Use the
finishing-a-development-branchskill
Invoke the skill. It will guide you through merge options.
The likely outcome: fast-forward merge db-reconciliation into main on the local repo (no PR — local-only tool).
- Step 3: Clean up the worktree
After merge, the skill will offer to remove .worktrees/db-reconciliation/. Accept.
- Step 4: Verify main is at the expected state
git log --oneline | head -10
Expected: ~40 new commits ahead of cf9af8e (the spec commit), all clean.
Self-review (against the spec)
After writing the plan, walk through each spec section and confirm coverage:
| Spec § | Coverage |
|---|---|
| 1. Overview | n/a (narrative) |
| 2. Goals | T1 (SQLAlchemy dep), T9 (store facade), T10 (reconcile.run), T22 (status badge), T23 (adjustmentAmount column), T3 (migrations) |
| 3. Non-goals | respected (no CAS reason-code explanations, no multi-user, no auth) |
| 4. Stack | T1 (SQLAlchemy dep), T18-T26 (frontend additions with no new npm deps) |
| 5. Architecture | T9 (CycloneStore as facade), T7-T8 (reconcile pure fns), T14-T15 (API endpoints) |
| 6. Data model | T3 (SQL migration), T4-T6 (6 ORM models) |
| 7. Reconciliation | T7 (match), T8 (apply_payment / apply_reversal / split_unmatched), T10 (run orchestrator) |
| 8. Migration runner | T3 (cyclone.db_migrate + 0001_initial) |
| 9. Store facade | T9 (CycloneStore class with all public methods) |
| 10. API additions | T14, T15, T16 (3 new endpoints + parse-835 summary) |
| 11. Frontend additions | T18 (types), T19 (api.ts), T20 (useReconciliation), T21 (ClaimStateBadge), T24 (page), T25 (sidebar) |
| 12. Data flow | covered via integration tests in T10, T11, T12, T16 |
| 13. Error handling | T10 (reconcile_failed path), T14-T15 (HTTP error codes), T8 (skipped intents) |
| 14. Testing | ~32 backend (T2+T3+T4+T5+T6+T7+T8+T10+T11+T12+T14+T15+T16) + 4 frontend (T20+T24) |
| 15. Migration / rollout | T13 (db.init() in startup), T27 (README docs) |
| 16. Out of scope | respected (no Alembic, no field rename, no multi-user) |
| 17. Acceptance | T29 (smoke), T17 (TODO marker removed) |
Placeholder scan: no TBD, TODO, TBA, "implement later", "fill in details", "add appropriate error handling", "similar to Task N" without context, "write tests" without code.
Type consistency: ClaimState is str-enum in Python (used as "submitted" etc. via .value) and ClaimState is a TS union type in src/types/index.ts. The mapper to_ui_claim writes c.state.value to the API response; the frontend reads claim.state as a string and passes it to <ClaimStateBadge state={...}>. Manual_match raises AlreadyMatchedError / InvalidStateError; the API converts them to 409. ApplyIntent is consistent across apply_payment, apply_reversal, and reconcile.run. Match (reconcile result) vs Match (ORM model) — reconciled by name Match in reconcile.py is the dataclass; the ORM model in db.py is Match. There is a name collision that the tests guard against by importing from explicit modules. No ambiguity at runtime because reconcile.match() returns the dataclass and db.Match is only used inside SQLAlchemy sessions.
No spec gaps found. All 17 spec sections are covered.
Execution
After saving this plan, choose an execution mode:
- Subagent-Driven (recommended) — fresh subagent per task, two-stage review between tasks, fast iteration. Requires
superpowers:subagent-driven-development. - Inline Execution — execute tasks in this session using
superpowers:executing-plans, batch execution with checkpoints.