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


---



---



---

