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