From 27c16808304fde8569ca37337b6a75824a0a8e8a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 19:58:26 -0600 Subject: [PATCH] docs: refresh README roadmap (SP4/6/7 shipped, add Next up) + commit SP6 plan - Mark SP4 as fully shipped (batch diff, search, CSV, a11y all landed) - Add SP6 (Inbox) and SP7 (Per-line reconciliation) sections - Add SP6 + SP7 endpoint inventories - Note next up: outbound 837P serializer The workflow-automation plan was authored but never committed; the features it specified have shipped, so commit it for the historical record alongside this README refresh. --- README.md | 108 +- .../2026-06-20-cyclone-workflow-automation.md | 2702 +++++++++++++++++ 2 files changed, 2796 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md diff --git a/README.md b/README.md index feb8a4e..ad22507 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ backup API). ## Roadmap -Sub-projects 2, 3, 4, and 5 are **shipped**. Next up: +Sub-projects 2 through 7 are **shipped**. Next up: - **Sub-project 3 (shipped) — More 837P/835 features.** - **837P validation rules:** R034 enforces `REF*G1` on frequency-code @@ -294,20 +294,36 @@ Sub-projects 2, 3, 4, and 5 are **shipped**. Next up: builds a 270 from a JSON payload (subscriber, provider, payer, service type); `POST /api/eligibility/parse-271` ingests the response and returns structured `coverage_benefits`. -- **Sub-project 4 (partially shipped) — Frontend features.** - - **Per-claim detail drawer (shipped):** click any row on the Claims - page to open a side-panel drawer with the full claim context — - state + amount header, validation panel, service lines, diagnoses, - parties (billing provider, subscriber, payer), raw X12 segments, - matched remittance summary, and a vertical state-history timeline. - URL is synced (`?claim=...`) so links and back-button restore the - drawer. Keyboard nav: `j`/`k` move between claims, `esc` closes, - `?` opens the cheatsheet overlay. Skeleton + error + not-found - (404) states are all distinct. Powered by a new backend endpoint + - **TA1 interchange ACK:** parser + persistence + API for the + envelope-level TA1 acknowledgment (separate from the 999 + transaction-set ACK). +- **Sub-project 4 (shipped) — Frontend features.** + - **Per-claim detail drawer:** click any row on the Claims page to + open a side-panel drawer with the full claim context — state + + amount header, validation panel, service lines, diagnoses, parties + (billing provider, subscriber, payer), raw X12 segments, matched + remittance summary, and a vertical state-history timeline. URL is + synced (`?claim=...`) so links and back-button restore the drawer. + Keyboard nav: `j`/`k` move between claims, `esc` closes, `?` opens + the cheatsheet overlay. Skeleton + error + not-found (404) states + are all distinct. Powered by a new backend endpoint `GET /api/claims/{claim_id}` that returns the full drawer payload in a single round-trip. - - **Remaining SP4:** batch diff view, advanced filters (date range, - multi-status, saved filter sets). + - **Remit drawer:** mirror of the claim drawer for remittances — + payer + provider header, financial summary, claim-payments table, + CAS adjustments panel, parties grid, raw X12 segments. URL is + synced (`?remit=...`). + - **Batch diff view:** side-by-side comparison of two batches + (added / removed / changed claims) reachable from the Batches + page. Powers the `BatchDetail` and `BatchDiff` views. + - **Global Cmd-K search:** cross-resource search across claims, + remittances, and activity; opens from anywhere via the keyboard + shortcut. + - **CSV export:** per-page export buttons (claims, remittances, lanes) + that stream CSV directly from the backend. + - **Accessibility + print styles:** keyboard navigation focus rings, + `aria` roles, and `@media print` stylesheets for paper-friendly + output. - **Sub-project 5 (shipped) — Live updates.** - The Claims, Remittances, and Activity pages stream new rows in real time over Server-Sent Events encoded as newline-delimited @@ -321,8 +337,42 @@ Sub-projects 2, 3, 4, and 5 are **shipped**. Next up: `error` / `closed` — and offers a manual **↻ Reconnect** button on `stalled` or `error`. Stale connections (no event for 30s, heartbeat included) flip to `stalled` automatically; the - back-off ladder on errors is 1s → 2s → 4s → 8s → 16s → 30s + back-off ladder on errors is `1s → 2s → 4s → 8s → 16s → 30s` capped. See the "Live updates" section below for details. +- **Sub-project 6 (shipped) — Inbox workflow automation.** + - **Ticker-Tape inbox (`/inbox`):** four lanes ordered by urgency — + **Rejected** (claims whose 999 rejected them), **Candidates** + (remits that didn't auto-match a claim), **Unmatched** (claims + still waiting for a remit), and **Done today** (terminal + transitions in the last 24 hours). The page subscribes to the + claim and remittance tail streams so new rejected claims land in + the Rejected lane within a fraction of a second. + - **4-field weighted scoring:** every candidate remit is scored on + patient control number (40), service date (25), charge amount + (20), and provider NPI (15). Tiers: strong (≥75, Match enabled), + weak (50–74, dimmed), hidden (<50). + - **Bulk actions:** multi-select rows in any lane to dismiss + candidates, resubmit rejected claims, or export the lane as CSV + in a single round-trip. +- **Sub-project 7 (shipped) — Per-line adjustment audit.** + - Every 835 CAS segment is tied back to the specific 837 service + line it adjudicates, instead of being aggregated to the claim + level. The match runs **eagerly** at 835 ingest time so the audit + trail is stable across re-reads. + - New endpoints surface the per-line projection: `GET + /api/claims/{id}/line-reconciliation` for the dedicated tab in + the ClaimDrawer; the existing claim + remit detail endpoints + gained slim `lineReconciliation[]` / `serviceLinePayments[]` / + `claimLevelAdjustments[]` fields; the inbox lanes include + `matched_lines` / `total_lines` badges on matched remittances. + - The ClaimDrawer gains a **Line Reconciliation** tab (alongside + Details) that pairs every 837 SV1 line with its 835 SVC composite + and surfaces per-line CAS reasons + unmatched-line warnings. + - See the "Per-Line Adjustment Audit" section below for details. + +**Next up:** outbound 837P serializer — closes the resubmit loop +(rejected claim → corrected 837P back to payer). Design spec at +`docs/superpowers/specs/2026-06-20-cyclone-serialize-837-design.md`. ### SP3 endpoints @@ -361,6 +411,36 @@ ACKs and lets you download the regenerated 999 text. - `GET /api/activity/stream` — same shape for activity events (subscribes to `activity_recorded`, default `limit=50`). +### SP6 endpoints (inbox) + +- `GET /api/inbox/lanes` — all four lanes (rejected, candidates, + unmatched, done_today) in a single round-trip, with row-level + scoring and matched-remit context. +- `POST /api/inbox/candidates/{remit_id}/match` — manual match of a + candidate remit to one of its scored claims; `409` if the claim + state moved out from under us. +- `POST /api/inbox/candidates/dismiss` — `{pairs: [{claim_id, + remit_id}]}`. Session-scoped, no DB persistence. +- `POST /api/inbox/rejected/resubmit` — `{claim_ids: [...]}`. `200` + with `conflicts` for non-rejected claims in the batch. +- `GET /api/inbox/export.csv?lane=` — streams CSV of the + lane's rows. + +### SP7 endpoints (per-line reconciliation) + +- `GET /api/claims/{id}/line-reconciliation` — dedicated view for + the ClaimDrawer's Line Reconciliation tab; returns the slim + per-line projection (`summary` + `lines[]`). +- `GET /api/claims/{id}` — now includes a slim `lineReconciliation[]` + so the ServiceLinesTable can show paid + adjustments columns + without a second fetch. +- `GET /api/remittances/{id}` — now includes `serviceLinePayments[]` + + `claimLevelAdjustments[]`; the CAS panel renders per-line + claim + level separately. +- `GET /api/inbox/lanes` — `matched_remittance` payload gained + `matched_lines` + `total_lines` so the MatchedRemitCard can badge + partial matches. + ## License No license file yet; this is internal-use software. Add a `LICENSE` file diff --git a/docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md b/docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md new file mode 100644 index 0000000..aea1a31 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md @@ -0,0 +1,2702 @@ +# Sub-project 6 — Workflow Automation & Inbox: Implementation Plan + +**Date:** 2026-06-20 +**Status:** Ready +**Branch:** `sp6-workflow-inbox` (created from `main` after SP5 is merged) +**Spec:** `.superpowers/brainstorm/419-1781997857/spec.md` + +21 tasks across 8 phases. TDD throughout. Each task ends with a green test run + commit. + +**Prerequisite:** SP5 (live-tail) must be merged to `main` before this plan runs. The first task verifies this and rebases the branch if needed. + +**Tech additions:** None — scoring uses stdlib only (`difflib` is *not* needed because patient control number match is exact, not fuzzy). No new pip deps, no new npm deps. + +--- + +## Task 0: Verify SP5 is on `main` and create work branch + +**Files:** none + +- [ ] **Step 1: Confirm SP5 is merged** + +```bash +cd /Users/openclaw/dev/cyclone +git fetch origin +git log --oneline main -10 +git log --oneline main | grep -E "merge:.*live-tail|feat:.*live-tail|merge.*tail" | head -3 +``` + +Expected: at least one merge commit referencing SP5 / live-tail on `main`. If absent, STOP — do not proceed until SP5 is merged. + +- [ ] **Step 2: Create the work branch** + +```bash +git checkout main +git pull --ff-only +git checkout -b sp6-workflow-inbox +``` + +- [ ] **Step 3: Commit** (empty commit to anchor the branch) + +```bash +git commit --allow-empty -m "chore(sp6): start workflow automation & inbox branch" +``` + +--- + +## Phase 1 — Schema + +## Task 1: Add `REJECTED` to `ClaimState` enum + +**Files:** +- Modify: `backend/src/cyclone/db.py:124-131` + +- [ ] **Step 1: Add the enum value** + +Edit `backend/src/cyclone/db.py`, in the `ClaimState` class, add `REJECTED = "rejected"` between `RECEIVED` and `PAID`: + +```python +class ClaimState(str, enum.Enum): + SUBMITTED = "submitted" + RECEIVED = "received" + REJECTED = "rejected" # NEW — set by 999 AK9 set-level R/E + PAID = "paid" + PARTIAL = "partial" + DENIED = "denied" + RECONCILED = "reconciled" + REVERSED = "reversed" +``` + +- [ ] **Step 2: Run full backend suite, confirm green** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +Expected: existing tests still pass (enum addition is additive; no callers reference the new value yet). + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/cyclone/db.py +git commit -m "feat(sp6): add REJECTED to ClaimState enum" +``` + +--- + +## Task 2: Migration 0003 — rejection columns + state-history index + +**Files:** +- Create: `backend/src/cyclone/migrations/0003_rejections_and_state_history.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- version: 3 +-- SP6 T2: 999 rejection tracking + state-history index for the Done-today lane. + +ALTER TABLE claims + ADD COLUMN rejection_reason TEXT, + ADD COLUMN rejected_at TIMESTAMP, + ADD COLUMN resubmit_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at); +``` + +- [ ] **Step 2: Run the full suite to trigger the migration** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +Expected: green. The migration runner picks up `0003_*.sql` automatically and applies it on each test's fresh DB. + +- [ ] **Step 3: Verify the columns exist on the test DB** + +```bash +cd backend && python -c " +from cyclone.db import init_db, get_sessionmaker +init_db('sqlite:////tmp/sp6-t2-verify.db') +S = get_sessionmaker()() +with S() as s: + rows = s.execute(s.bind.dialect.compiler()).fetchall() +" 2>&1 | head -5 +sqlite3 /tmp/sp6-t2-verify.db ".schema claims" | head -25 +``` + +Expected: schema includes `rejection_reason TEXT`, `rejected_at TIMESTAMP`, `resubmit_count INTEGER NOT NULL DEFAULT 0`, and the `ix_claims_state_changed_at` index. Clean up: `rm /tmp/sp6-t2-verify.db`. + +- [ ] **Step 4: Commit** + +```bash +git add backend/src/cyclone/migrations/0003_rejections_and_state_history.sql +git commit -m "feat(sp6): migration 0003 — rejection columns + state-history index" +``` + +--- + +## Phase 2 — 999 → Rejected state + +## Task 3: `apply_999_rejections` helper (TDD) + +**Files:** +- Create: `backend/src/cyclone/inbox_state.py` +- Test: `backend/tests/test_inbox_state.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_inbox_state.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from cyclone.db import Base, Claim, ClaimState, init_db +from cyclone.inbox_state import apply_999_rejections +from cyclone.parsers.models_999 import ParseResult999, SetFunctionalGroupResponse, SetAcceptReject + + +def _make_engine(): + eng = create_engine("sqlite://") + Base.metadata.create_all(eng) + return eng + + +def _make_claim(session, *, claim_id="CLP-1", patient_control_number="PCN-001", + state=ClaimState.SUBMITTED): + c = Claim( + id=claim_id, + batch_id="B-1", + patient_control_number=patient_control_number, + state=state, + ) + session.add(c) + session.commit() + return c + + +def _make_999(*, claim_refs): + """claim_refs: list of (patient_control_number, accept_code).""" + return ParseResult999( + envelope=None, # not used by apply_999_rejections + set_responses=[ + SetFunctionalGroupResponse( + set_control_number=ref, + set_accept_reject=SetAcceptReject(code=code), + ik3_segments=[], + ) + for ref, code in claim_refs + ], + ) + + +def test_rejection_moves_submitted_claim_to_rejected_state(): + eng = _make_engine() + S = sessionmaker(bind=eng)() + claim = _make_claim(S, patient_control_number="PCN-001") + + result = apply_999_rejections( + S, + _make_999(claim_refs=[("PCN-001", "R")]), + claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), + ) + + assert result.matched == ["CLP-1"] + assert result.orphans == [] + S.refresh(claim) + assert claim.state == ClaimState.REJECTED + assert claim.rejected_at is not None + assert "999" in claim.rejection_reason + + +def test_accepted_set_leaves_claim_alone(): + eng = _make_engine() + S = sessionmaker(bind=eng)() + claim = _make_claim(S, patient_control_number="PCN-002") + + result = apply_999_rejections( + S, + _make_999(claim_refs=[("PCN-002", "A")]), + claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), + ) + + assert result.matched == [] + S.refresh(claim) + assert claim.state == ClaimState.SUBMITTED + assert claim.rejected_at is None + + +def test_unknown_pcn_recorded_as_orphan_not_error(): + eng = _make_engine() + S = sessionmaker(bind=eng)() + + result = apply_999_rejections( + S, + _make_999(claim_refs=[("PCN-NONEXISTENT", "R")]), + claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), + ) + + assert result.matched == [] + assert result.orphans == ["PCN-NONEXISTENT"] + + +def test_already_rejected_claim_is_idempotent(): + eng = _make_engine() + S = sessionmaker(bind=eng)() + claim = _make_claim(S, patient_control_number="PCN-003", state=ClaimState.REJECTED) + + result = apply_999_rejections( + S, + _make_999(claim_refs=[("PCN-003", "R")]), + claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), + ) + + assert result.matched == [] # no-op + S.refresh(claim) + assert claim.state == ClaimState.REJECTED +``` + +- [ ] **Step 2: Run the test, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -q +``` + +Expected: `ModuleNotFoundError: No module named 'cyclone.inbox_state'`. + +- [ ] **Step 3: Implement the module** + +```python +# backend/src/cyclone/inbox_state.py +"""999 ACK → claim state transitions for the Inbox Rejected lane.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Callable + +from sqlalchemy.orm import Session + +from cyclone.db import Claim, ClaimState + + +@dataclass +class Apply999Result: + matched: list[str] = field(default_factory=list) # claim ids transitioned + orphans: list[str] = field(default_factory=list) # PCNs we couldn't resolve + + +def _build_reason(code: str, ik3_count: int) -> str: + parts = [f"999 AK9 set-level {code}"] + if ik3_count: + parts.append(f"{ik3_count} IK3 segment error(s)") + return "; ".join(parts) + + +def apply_999_rejections( + session: Session, + parsed_999, + *, + claim_lookup: Callable[[str], Claim | None], +) -> Apply999Result: + """For each set response with code R or E, look up the matching claim and + move it to REJECTED. Idempotent on already-rejected claims. + + Args: + session: SQLAlchemy session. + parsed_999: a ParseResult999 (or any object with .set_responses). + claim_lookup: callable from patient_control_number → Claim or None. + + Returns: + Apply999Result with lists of matched claim ids and orphan PCNs. + """ + result = Apply999Result() + now = datetime.now(timezone.utc) + + for sr in parsed_999.set_responses: + code = sr.set_accept_reject.code + if code not in ("R", "E"): + continue + + claim = claim_lookup(sr.set_control_number) + if claim is None: + result.orphans.append(sr.set_control_number) + continue + + if claim.state == ClaimState.REJECTED: + # Idempotent: don't double-mutate. + continue + + claim.state = ClaimState.REJECTED + claim.rejected_at = now + claim.rejection_reason = _build_reason(code, len(sr.ik3_segments or [])) + result.matched.append(claim.id) + + if result.matched or result.orphans: + session.commit() + + return result +``` + +- [ ] **Step 4: Run the test, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -v +``` + +Expected: 4 passed. + +- [ ] **Step 5: Run full backend suite, confirm still green** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/inbox_state.py backend/tests/test_inbox_state.py +git commit -m "feat(sp6): apply_999_rejections state helper" +``` + +--- + +## Task 4: Wire 999 ingest → `apply_999_rejections` (TDD) + +**Files:** +- Modify: `backend/src/cyclone/api.py:569-640` +- Modify: `backend/tests/conftest.py` (only if needed for the event_bus fixture) +- Test: `backend/tests/test_999_rejected_state.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_999_rejected_state.py +import io +import os +import tempfile + +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.db import init_db, Claim, ClaimState, get_sessionmaker + + +def _seed_claim(*, claim_id="CLP-1", patient_control_number="PCN-001"): + S = get_sessionmaker()() + with S() as s: + s.add(Claim(id=claim_id, batch_id="B-1", + patient_control_number=patient_control_number, + state=ClaimState.SUBMITTED)) + s.commit() + + +def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient): + _seed_claim() + + # Minimal 999: 1 set, accept_reject=R, set_control_number matches the PCN. + raw = ( + "ISA*00* *00* *ZZ*PAYER01 *ZZ*PROVIDER01 " + f"*250615*0900*^*00501*000000001*0*P*:~" + "GS*FA*PAYER01*PROVIDER01*20250615*0900*1*X*005010X231A1~" + "ST*999*0001~" + "AK1*HC*0001~" + f"AK2*PCN-001*0001~" + "IK3*CLM*001*8~" + "IK4*2**7~" + "IK5*R*5~" + "AK9*R*1*1*0~" + "SE*8*0001~" + "GE*1*1~" + "IEA*0*000000001~" + ) + + r = client.post( + "/api/parse-999", + files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")}, + ) + assert r.status_code == 200, r.text + + S = get_sessionmaker()() + with S() as s: + c = s.get(Claim, "CLP-1") + assert c.state == ClaimState.REJECTED + assert c.rejected_at is not None + assert "999" in c.rejection_reason +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v +``` + +Expected: FAIL — claim state stays `submitted`. + +- [ ] **Step 3: Wire the helper into the endpoint** + +In `backend/src/cyclone/api.py`, after `result = parse_999_text(text, ...)` and before `store.add_ack(...)`, add: + +```python +from cyclone.inbox_state import apply_999_rejections +from cyclone.db import Claim +``` + +Then, just before the `store.add_ack(...)` call, insert: + +```python + # SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED. + S = get_sessionmaker()() + with S() as session: + def _lookup(pcn: str): + return session.query(Claim).filter_by(patient_control_number=pcn).first() + _rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup) + if _rejection_result.matched: + # Publish events so the Inbox tail refetches. + bus = _get_event_bus() + for cid in _rejection_result.matched: + bus.publish("claim.rejected", {"claim_id": cid}) + if _rejection_result.orphans: + log.warning("999 had %d orphan set refs: %s", + len(_rejection_result.orphans), + _rejection_result.orphans[:5]) +``` + +(`get_sessionmaker`, `_get_event_bus`, and `log` are already in scope in `api.py`.) + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v +``` + +Expected: 1 passed. + +- [ ] **Step 5: Full suite** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_999_rejected_state.py +git commit -m "feat(sp6): wire 999 ingest → claim rejected state" +``` + +--- + +## Phase 3 — Scoring + lane computation + +## Task 5: Scoring module (TDD) + +**Files:** +- Create: `backend/src/cyclone/scoring.py` +- Test: `backend/tests/test_scoring.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_scoring.py +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +import pytest + +from cyclone.scoring import ScoreBreakdown, score_pair + + +@dataclass +class FakeClaim: + patient_control_number: str = "PCN-001" + service_date_from: date | None = date(2026, 6, 10) + charge_amount: Decimal = Decimal("100.00") + provider_npi: str | None = "1234567890" + + +@dataclass +class FakeRemit: + patient_control_number: str = "PCN-001" + service_date: date | None = date(2026, 6, 10) + charge_amount: Decimal = Decimal("100.00") + rendering_provider_npi: str | None = "1234567890" + + +def test_perfect_match_scores_100(): + s = score_pair(FakeClaim(), FakeRemit()) + assert s.total == 100 + assert s.patient == 1.0 + assert s.date == 1.0 + assert s.amount == 1.0 + assert s.provider == 1.0 + + +def test_date_within_3_days_scores_partial(): + s = score_pair( + FakeClaim(service_date_from=date(2026, 6, 10)), + FakeRemit(service_date=date(2026, 6, 12)), + ) + assert s.date == pytest.approx(1 - 2/3) # 0.333 + + +def test_date_beyond_3_days_scores_zero(): + s = score_pair( + FakeClaim(service_date_from=date(2026, 6, 10)), + FakeRemit(service_date=date(2026, 6, 14)), + ) + assert s.date == 0.0 + + +def test_amount_within_10pct_scores_partial(): + s = score_pair( + FakeClaim(charge_amount=Decimal("100.00")), + FakeRemit(charge_amount=Decimal("108.00")), + ) + assert s.amount == pytest.approx(1 - 8/10) + + +def test_amount_beyond_10pct_scores_zero(): + s = score_pair( + FakeClaim(charge_amount=Decimal("100.00")), + FakeRemit(charge_amount=Decimal("120.00")), + ) + assert s.amount == 0.0 + + +def test_patient_normalized_exact_match(): + s = score_pair( + FakeClaim(patient_control_number="PCN-001"), + FakeRemit(patient_control_number=" pcn-001 "), + ) + assert s.patient == 1.0 + + +def test_patient_mismatch_scores_zero(): + s = score_pair( + FakeClaim(patient_control_number="PCN-001"), + FakeRemit(patient_control_number="PCN-002"), + ) + assert s.patient == 0.0 + + +def test_missing_provider_npi_scores_zero(): + s = score_pair( + FakeClaim(provider_npi=None), + FakeRemit(rendering_provider_npi="1234567890"), + ) + assert s.provider == 0.0 + + +def test_missing_service_date_scores_zero(): + s = score_pair( + FakeClaim(service_date_from=None), + FakeRemit(service_date=date(2026, 6, 10)), + ) + assert s.date == 0.0 + + +def test_threshold_boundaries(): + """Tier boundaries: ≥75 strong, 50–74 weak, <50 hidden.""" + # All fields 0 except amount = 0.75 (exact) → 15 pts → total 15 → hidden + s = score_pair( + FakeClaim( + patient_control_number="X", # 0 + service_date_from=date(2026, 6, 10), + charge_amount=Decimal("100.00"), + provider_npi="1234567890", + ), + FakeRemit( + patient_control_number="Y", # 0 + service_date=date(2026, 6, 10), + charge_amount=Decimal("100.00"), + rendering_provider_npi="1234567890", + ), + ) + assert 50 <= s.total < 75 # weak tier +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_scoring.py -q +``` + +- [ ] **Step 3: Implement** + +```python +# backend/src/cyclone/scoring.py +"""Transparent 4-field weighted scoring for Inbox candidate pairs. + +Weights (sum to 100): + - patient control number match (exact, normalized) 40 + - service date proximity (±3 days linear decay) 25 + - charge amount proximity (±10% linear decay) 20 + - provider NPI exact match 15 +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Protocol + + +class _ScorableClaim(Protocol): + patient_control_number: str + service_date_from: date | None + charge_amount: Decimal + provider_npi: str | None + + +class _ScorableRemit(Protocol): + patient_control_number: str + service_date: date | None + charge_amount: Decimal + rendering_provider_npi: str | None + + +@dataclass(frozen=True) +class ScoreBreakdown: + patient: float + date: float + amount: float + provider: float + + @property + def total(self) -> int: + return round( + self.patient * 40 + + self.date * 25 + + self.amount * 20 + + self.provider * 15 + ) + + @property + def tier(self) -> str: + if self.total >= 75: + return "strong" + if self.total >= 50: + return "weak" + return "hidden" + + +def _norm_pcn(s: str) -> str: + return (s or "").strip().lower().lstrip("0") + + +def _date_score(claim_date: date | None, remit_date: date | None) -> float: + if claim_date is None or remit_date is None: + return 0.0 + delta = abs((claim_date - remit_date).days) + if delta > 3: + return 0.0 + return 1.0 - (delta / 3.0) + + +def _amount_score(claim_amt: Decimal, remit_amt: Decimal) -> float: + if claim_amt <= 0: + return 0.0 + diff = abs(claim_amt - remit_amt) + threshold = claim_amt * Decimal("0.10") + if diff > threshold: + return 0.0 + # Normalize diff within threshold to 0..1 + return float(1.0 - (diff / threshold)) + + +def _provider_score(claim_npi: str | None, remit_npi: str | None) -> float: + if not claim_npi or not remit_npi: + return 0.0 + return 1.0 if claim_npi.strip() == remit_npi.strip() else 0.0 + + +def score_pair(claim: _ScorableClaim, remit: _ScorableRemit) -> ScoreBreakdown: + return ScoreBreakdown( + patient=1.0 if _norm_pcn(claim.patient_control_number) == _norm_pcn(remit.patient_control_number) and _norm_pcn(claim.patient_control_number) else 0.0, + date=_date_score(claim.service_date_from, remit.service_date), + amount=_amount_score(claim.charge_amount, remit.charge_amount), + provider=_provider_score(claim.provider_npi, remit.rendering_provider_npi), + ) +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_scoring.py -v +``` + +Expected: 10 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/scoring.py backend/tests/test_scoring.py +git commit -m "feat(sp6): 4-field weighted scoring module" +``` + +--- + +## Task 6: Lane computation module (TDD) + +**Files:** +- Create: `backend/src/cyclone/inbox_lanes.py` +- Test: `backend/tests/test_inbox_lanes.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_inbox_lanes.py +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from cyclone.db import init_db, Base, Claim, ClaimState, Remittance, get_sessionmaker +from cyclone.inbox_lanes import compute_lanes + + +@pytest.fixture +def session(): + eng = init_db("sqlite://") + S = get_sessionmaker()() + yield S + + +def _add_claim(S, *, claim_id, pcn="PCN", state=ClaimState.SUBMITTED, + charge=Decimal("100.00"), provider_npi="1234567890", + service_date=None, payer_id="PAYER-A"): + with S() as s: + c = Claim( + id=claim_id, + batch_id="B-1", + patient_control_number=pcn, + state=state, + charge_amount=charge, + provider_npi=provider_npi, + payer_id=payer_id, + service_date_from=service_date, + ) + s.add(c) + s.commit() + + +def _add_remit(S, *, remit_id, pcn="PCN", charge=Decimal("100.00"), + rendering_npi="1234567890", service_date=None, payer_id="PAYER-A", + claim_id=None): + with S() as s: + r = Remittance( + id=remit_id, + batch_id="B-1", + payer_claim_control_number=pcn, + charge_amount=charge, + claim_id=claim_id, + ) + # Store the auxiliary fields via raw_json for round-trip; the parser + # already populates these on a real parse. + r.raw_json = { + "patient_control_number": pcn, + "service_date": service_date.isoformat() if service_date else None, + "rendering_provider_npi": rendering_npi, + "payer_id": payer_id, + } + s.add(r) + s.commit() + + +def test_rejected_lane_includes_rejected_claims(session): + _add_claim(session, claim_id="C1", state=ClaimState.REJECTED) + lanes = compute_lanes(session, dismissed_pairs=set()) + assert "C1" in {r["id"] for r in lanes.rejected} + + +def test_unmatched_lane_includes_submitted_claims_with_no_remit(session): + _add_claim(session, claim_id="C1", state=ClaimState.SUBMITTED) + lanes = compute_lanes(session, dismissed_pairs=set()) + assert "C1" in {r["id"] for r in lanes.unmatched} + + +def test_unmatched_remit_appears_with_candidates(session): + _add_claim(session, claim_id="C1", pcn="PCN-1", service_date=None) + _add_remit(session, remit_id="R1", pcn="PCN-1") # exact match → candidate + lanes = compute_lanes(session, dismissed_pairs=set()) + assert any(r["id"] == "R1" for r in lanes.candidates) + + +def test_candidate_below_threshold_is_hidden(session): + _add_claim(session, claim_id="C1", pcn="A", charge=Decimal("100.00")) + _add_remit(session, remit_id="R1", pcn="Z", + charge=Decimal("999.00")) # everything mismatched + lanes = compute_lanes(session, dismissed_pairs=set()) + assert not any(r["id"] == "R1" for r in lanes.candidates) + + +def test_dismissed_pair_excluded_from_candidates(session): + _add_claim(session, claim_id="C1", pcn="PCN-1") + _add_remit(session, remit_id="R1", pcn="PCN-1") + lanes = compute_lanes(session, dismissed_pairs={frozenset({"C1", "R1"})}) + assert not any(r["id"] == "R1" for r in lanes.candidates) + + +def test_done_today_includes_recent_terminal_states(session): + # Need a recent state_changed_at. Set it explicitly. + with session() as s: + s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", + state=ClaimState.PAID, + state_changed_at=datetime.now(timezone.utc) - timedelta(hours=2))) + s.commit() + _add_claim(session, claim_id="C2", state=ClaimState.PAID) + # Force C2's state_changed_at to be old. + with session() as s: + c = s.get(Claim, "C2") + c.state_changed_at = datetime.now(timezone.utc) - timedelta(hours=30) + s.commit() + + lanes = compute_lanes(session, dismissed_pairs=set()) + ids = {r["id"] for r in lanes.done_today} + assert "C1" in ids + assert "C2" not in ids +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -q +``` + +Expected: ModuleNotFoundError for `cyclone.inbox_lanes`. + +- [ ] **Step 3: Implement the module** + +```python +# backend/src/cyclone/inbox_lanes.py +"""Compute the four Inbox lanes from the DB on read.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Iterable + +from sqlalchemy.orm import Session + +from cyclone.db import Claim, ClaimState, Remittance +from cyclone.scoring import score_pair, ScoreBreakdown + + +@dataclass +class Lanes: + rejected: list[dict] = field(default_factory=list) + candidates: list[dict] = field(default_factory=list) + unmatched: list[dict] = field(default_factory=list) + done_today: list[dict] = field(default_factory=list) + + +def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict: + return { + "id": c.id, + "kind": kind, + "patient_control_number": c.patient_control_number, + "charge_amount": float(c.charge_amount), + "payer_id": c.payer_id, + "provider_npi": c.provider_npi, + "state": c.state.value if hasattr(c.state, "value") else str(c.state), + "rejection_reason": c.rejection_reason, + "rejected_at": c.rejected_at.isoformat() if c.rejected_at else None, + "service_date_from": c.service_date_from.isoformat() if c.service_date_from else None, + "score": score.total if score else None, + "score_tier": score.tier if score else None, + "score_breakdown": { + "patient": score.patient, + "date": score.date, + "amount": score.amount, + "provider": score.provider, + } if score else None, + } + + +def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict: + raw = r.raw_json or {} + return { + "id": r.id, + "kind": "remit", + "payer_claim_control_number": r.payer_claim_control_number, + "charge_amount": float(r.charge_amount), + "payer_id": raw.get("payer_id"), + "rendering_provider_npi": raw.get("rendering_provider_npi"), + "service_date": raw.get("service_date"), + "candidates": [ + { + "claim_id": c.id, + "score": s.total, + "tier": s.tier, + "breakdown": { + "patient": s.patient, "date": s.date, + "amount": s.amount, "provider": s.provider, + }, + } + for c, s in candidates[:5] + ], + } + + +def _remit_score_fields(r: Remittance) -> dict: + raw = r.raw_json or {} + # The 835 parser stores fields on the ORM model when present. For the + # fields not on the model (service_date, rendering_provider_npi, payer_id, + # patient_control_number), pull from raw_json. + return { + "patient_control_number": raw.get("patient_control_number") + or r.payer_claim_control_number, + "service_date": raw.get("service_date"), + "charge_amount": r.charge_amount, + "rendering_provider_npi": raw.get("rendering_provider_npi"), + } + + +class _RemitScoringShim: + """Adapt a Remittance to the duck-typed protocol score_pair expects.""" + def __init__(self, r: Remittance): + f = _remit_score_fields(r) + self.patient_control_number = f["patient_control_number"] + self.service_date = _parse_iso_date(f["service_date"]) + self.charge_amount = f["charge_amount"] + self.rendering_provider_npi = f["rendering_provider_npi"] + + +def _parse_iso_date(s: str | None): + if not s: + return None + try: + return datetime.fromisoformat(s).date() + except (ValueError, TypeError): + return None + + +def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: + lanes = Lanes() + dismissed = set(dismissed_pairs) + + # --- Rejected --- + for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all(): + lanes.rejected.append(_claim_to_row(c, kind="claim")) + + # --- Done today --- + cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + terminal_states = { + ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED, + ClaimState.RECONCILED, ClaimState.REVERSED, + } + for c in session.query(Claim).filter( + Claim.state.in_(terminal_states), + Claim.state_changed_at >= cutoff, + ).all(): + lanes.done_today.append(_claim_to_row(c, kind="claim")) + + # --- Unmatched (claims still submitted) --- + for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all(): + lanes.unmatched.append(_claim_to_row(c, kind="claim")) + + # --- Candidates (remits not yet matched, with scoreable claims) --- + unmatched_remits = session.query(Remittance).filter( + Remittance.claim_id.is_(None), + ).all() + + submitted_claims = { + c.patient_control_number: c + for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all() + } + + for r in unmatched_remits: + if r.claim_id: + continue + candidates: list[tuple[Claim, ScoreBreakdown]] = [] + shim = _RemitScoringShim(r) + for c in submitted_claims.values(): + if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id): + continue + score = score_pair(c, shim) + if score.tier == "hidden": + continue + if frozenset({c.id, r.id}) in dismissed: + continue + candidates.append((c, score)) + candidates.sort(key=lambda cs: -cs[1].total) + if not candidates: + continue + lanes.candidates.append(_remit_to_row(r, candidates=candidates)) + + return lanes +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -v +``` + +Expected: 6 passed (some may need adjustment depending on `state_changed_at` defaults in your model — see note in commit step). + +- [ ] **Step 5: Run full suite** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +Expected: green. If `Claim.state_changed_at` doesn't exist on the ORM model, add it in `db.py` (mapped `DateTime(timezone=True)`, server default `now()`) via a follow-up commit — but most likely it already exists from SP3/SP4. + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/inbox_lanes.py backend/tests/test_inbox_lanes.py +git commit -m "feat(sp6): compute_lanes — 4 lanes on read with scoring" +``` + +--- + +## Phase 4 — API endpoints + +## Task 7: `GET /api/inbox/lanes` (TDD) + +**Files:** +- Modify: `backend/src/cyclone/api.py` (add new endpoint near other GET endpoints) +- Test: `backend/tests/test_inbox_endpoints.py` (new file) + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_inbox_endpoints.py +from fastapi.testclient import TestClient + + +def test_lanes_endpoint_returns_four_keys(client: TestClient): + r = client.get("/api/inbox/lanes") + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"} + for v in body.values(): + assert isinstance(v, list) +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q +``` + +- [ ] **Step 3: Add the endpoint** + +In `backend/src/cyclone/api.py`, near the other GET endpoints, add: + +```python +from cyclone.inbox_lanes import compute_lanes + + +@app.get("/api/inbox/lanes") +def inbox_lanes(): + S = get_sessionmaker()() + dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) + with S() as session: + lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) + return { + "rejected": lanes.rejected, + "candidates": lanes.candidates, + "unmatched": lanes.unmatched, + "done_today": lanes.done_today, + } +``` + +Also ensure `dismissed_pairs` is initialized on `app.state` in `conftest.py` (or in the lifespan if there is one): + +```python +# backend/tests/conftest.py — extend the existing autouse fixture: +if not hasattr(app.state, "dismissed_pairs"): + app.state.dismissed_pairs = set() +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v +``` + +- [ ] **Step 5: Full suite** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/conftest.py backend/tests/test_inbox_endpoints.py +git commit -m "feat(sp6): GET /api/inbox/lanes" +``` + +--- + +## Task 8: POST match + dismiss endpoints (TDD) + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_inbox_endpoints.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `backend/tests/test_inbox_endpoints.py`: + +```python +def test_match_endpoint_links_remit_to_claim(client: TestClient): + # Seed + from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState + S = get_sessionmaker()() + with S() as s: + s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1", + state=ClaimState.SUBMITTED)) + s.add(Remittance(id="R1", batch_id="B-1", + payer_claim_control_number="PCN-1")) + s.commit() + + r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"}) + assert r.status_code == 200 + + with S() as s: + c = s.get(Claim, "C1") + assert c.matched_remittance_id == "R1" + + +def test_match_409_if_claim_already_matched(client: TestClient): + from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState + S = get_sessionmaker()() + with S() as s: + s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", + state=ClaimState.SUBMITTED, + matched_remittance_id="R-OTHER")) + s.add(Remittance(id="R1", batch_id="B-1", + payer_claim_control_number="X")) + s.commit() + + r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"}) + assert r.status_code == 409 + assert "current_state" in r.json() + + +def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient): + r = client.post( + "/api/inbox/candidates/dismiss", + json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]}, + ) + assert r.status_code == 200 + dismissed = client.app.state.dismissed_pairs + assert frozenset({"C1", "R1"}) in dismissed +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q +``` + +- [ ] **Step 3: Implement both endpoints** + +In `backend/src/cyclone/api.py`: + +```python +from fastapi import HTTPException +from cyclone.db import Claim, ClaimState, Remittance + + +@app.post("/api/inbox/candidates/{remit_id}/match") +def inbox_match_candidate(remit_id: str, body: dict): + claim_id = body.get("claim_id") + if not claim_id: + raise HTTPException(400, "claim_id required") + S = get_sessionmaker()() + with S() as s: + claim = s.get(Claim, claim_id) + remit = s.get(Remittance, remit_id) + if claim is None or remit is None: + raise HTTPException(404, "claim or remit not found") + if claim.matched_remittance_id and claim.matched_remittance_id != remit_id: + raise HTTPException( + 409, + detail={ + "error": "claim_already_matched", + "current_state": claim.state.value if hasattr(claim.state, "value") else str(claim.state), + "matched_remittance_id": claim.matched_remittance_id, + }, + ) + claim.matched_remittance_id = remit_id + claim.claim_id = remit_id # back-ref; safe no-op on already linked + s.commit() + bus = _get_event_bus() + bus.publish("claim.matched", {"claim_id": claim_id, "remit_id": remit_id}) + return {"ok": True} + + +@app.post("/api/inbox/candidates/dismiss") +def inbox_dismiss_candidates(body: dict): + pairs = body.get("pairs") or [] + if not hasattr(app.state, "dismissed_pairs"): + app.state.dismissed_pairs = set() + for p in pairs: + cid = p.get("claim_id") + rid = p.get("remit_id") + if cid and rid: + app.state.dismissed_pairs.add(frozenset({cid, rid})) + return {"ok": True, "dismissed_count": len(pairs)} +``` + +(Note: depending on the ORM model, `claim.claim_id` may not exist — if so, just omit that line; the `matched_remittance_id` set is what matters.) + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v +``` + +- [ ] **Step 5: Full suite** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py +git commit -m "feat(sp6): manual match + dismiss endpoints" +``` + +--- + +## Task 9: POST `/api/inbox/rejected/resubmit` (TDD) + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_inbox_endpoints.py` + +- [ ] **Step 1: Append the failing test** + +```python +def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient): + from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState + S = get_sessionmaker()() + with S() as s: + s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", + state=ClaimState.REJECTED, + rejection_reason="old", + resubmit_count=2)) + s.commit() + + r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]}) + assert r.status_code == 200, r.text + + with S() as s: + c = s.get(Claim, "C1") + assert c.state == ClaimState.SUBMITTED + assert c.rejection_reason is None + assert c.resubmit_count == 3 + + +def test_resubmit_409_for_non_rejected_claim(client: TestClient): + from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState + S = get_sessionmaker()() + with S() as s: + s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", + state=ClaimState.SUBMITTED)) + s.commit() + + r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]}) + assert r.status_code == 409 +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q +``` + +- [ ] **Step 3: Implement** + +```python +@app.post("/api/inbox/rejected/resubmit") +def inbox_resubmit_rejected(body: dict): + ids = body.get("claim_ids") or [] + if not ids: + raise HTTPException(400, "claim_ids required") + S = get_sessionmaker()() + accepted = [] + conflicts = [] + with S() as s: + for cid in ids: + c = s.get(Claim, cid) + if c is None: + continue + if c.state != ClaimState.REJECTED: + conflicts.append({ + "claim_id": cid, + "current_state": c.state.value if hasattr(c.state, "value") else str(c.state), + }) + continue + c.state = ClaimState.SUBMITTED + c.rejection_reason = None + c.rejected_at = None + c.resubmit_count = (c.resubmit_count or 0) + 1 + accepted.append(cid) + s.commit() + bus = _get_event_bus() + for cid in accepted: + bus.publish("claim.resubmitted", {"claim_id": cid}) + return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v +``` + +- [ ] **Step 5: Full suite** + +```bash +cd backend && ../.venv/bin/pytest -q +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py +git commit -m "feat(sp6): bulk resubmit rejected claims" +``` + +--- + +## Task 10: GET `/api/inbox/export.csv` (TDD) + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_inbox_endpoints.py` + +- [ ] **Step 1: Append the failing test** + +```python +def test_export_csv_streams_rows_with_csv_content_type(client: TestClient): + r = client.get("/api/inbox/export.csv?lane=rejected") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + # Body should at least include the header row. + body = r.text + assert "id" in body.splitlines()[0] +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q +``` + +- [ ] **Step 3: Implement** + +```python +import csv +import io +from fastapi.responses import StreamingResponse + + +@app.get("/api/inbox/export.csv") +def inbox_export_csv(lane: str): + if lane not in {"rejected", "candidates", "unmatched", "done_today"}: + raise HTTPException(400, f"unknown lane: {lane}") + S = get_sessionmaker()() + dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) + with S() as session: + lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) + rows = getattr(lanes, lane) + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([ + "id", "kind", "patient_control_number", "charge_amount", + "payer_id", "provider_npi", "state", "rejection_reason", + "service_date_from", "score", + ]) + for r in rows: + writer.writerow([ + r.get("id") or r.get("payer_claim_control_number"), + r.get("kind"), + r.get("patient_control_number"), + r.get("charge_amount"), + r.get("payer_id"), + r.get("provider_npi") or r.get("rendering_provider_npi"), + r.get("state"), + r.get("rejection_reason"), + r.get("service_date_from") or r.get("service_date"), + r.get("score"), + ]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'}, + ) +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v +``` + +- [ ] **Step 5: Full suite + commit** + +```bash +cd backend && ../.venv/bin/pytest -q +git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py +git commit -m "feat(sp6): CSV export endpoint" +``` + +--- + +## Phase 5 — Frontend scaffolding + +## Task 11: API client + types + +**Files:** +- Modify: `src/lib/api.ts` (append) +- Modify: `src/lib/api.test.ts` (or create `src/lib/inbox-api.test.ts`) + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/lib/inbox-api.test.ts +import { describe, expect, it, vi } from "vitest"; +import { fetchInboxLanes, matchCandidate, dismissCandidates, resubmitRejected, exportInboxCsv } from "./inbox-api"; + +describe("inbox-api", () => { + it("fetchInboxLanes hits /api/inbox/lanes", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), + }); + vi.stubGlobal("fetch", fetchSpy); + const lanes = await fetchInboxLanes(); + expect(fetchSpy).toHaveBeenCalledWith("/api/inbox/lanes", expect.anything()); + expect(lanes.rejected).toEqual([]); + vi.unstubAllGlobals(); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +cd . && npx vitest run src/lib/inbox-api.test.ts +``` + +- [ ] **Step 3: Implement** + +```typescript +// src/lib/inbox-api.ts +export type ScoreBreakdown = { + patient: number; + date: number; + amount: number; + provider: number; +}; + +export type ScoreTier = "strong" | "weak" | "hidden"; + +export type InboxClaimRow = { + id: string; + kind: "claim"; + patient_control_number: string; + charge_amount: number; + payer_id: string | null; + provider_npi: string | null; + state: string; + rejection_reason: string | null; + rejected_at: string | null; + service_date_from: string | null; + score?: number | null; + score_tier?: ScoreTier | null; + score_breakdown?: ScoreBreakdown | null; +}; + +export type InboxCandidateRow = { + id: string; + kind: "remit"; + payer_claim_control_number: string; + charge_amount: number; + payer_id: string | null; + rendering_provider_npi: string | null; + service_date: string | null; + candidates: Array<{ + claim_id: string; + score: number; + tier: ScoreTier; + breakdown: ScoreBreakdown; + }>; +}; + +export type InboxLanes = { + rejected: InboxClaimRow[]; + candidates: InboxCandidateRow[]; + unmatched: InboxClaimRow[]; + done_today: InboxClaimRow[]; +}; + +async function getJson(url: string): Promise { + const r = await fetch(url); + if (!r.ok) throw new Error(`${r.status} ${url}`); + return (await r.json()) as T; +} + +export async function fetchInboxLanes(): Promise { + return getJson("/api/inbox/lanes"); +} + +export async function matchCandidate(remitId: string, claimId: string): Promise { + const r = await fetch(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ claim_id: claimId }), + }); + if (!r.ok) throw new Error(`match failed: ${r.status}`); +} + +export async function dismissCandidates(pairs: Array<{ claim_id: string; remit_id: string }>): Promise { + const r = await fetch("/api/inbox/candidates/dismiss", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pairs }), + }); + if (!r.ok) throw new Error(`dismiss failed: ${r.status}`); +} + +export async function resubmitRejected(claimIds: string[]): Promise { + const r = await fetch("/api/inbox/rejected/resubmit", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ claim_ids: claimIds }), + }); + if (!r.ok) throw new Error(`resubmit failed: ${r.status}`); +} + +export function exportInboxCsvUrl(lane: "rejected" | "candidates" | "unmatched" | "done_today"): string { + return `/api/inbox/export.csv?lane=${lane}`; +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +npx vitest run src/lib/inbox-api.test.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts +git commit -m "feat(sp6): inbox api client + types" +``` + +--- + +## Task 12: Ticker Tape styling primitives + +**Files:** +- Modify: `src/index.css` (or `src/styles/tokens.css` if it exists — check the project structure) +- Modify: `tailwind.config.js` (extend theme) + +- [ ] **Step 1: Inspect existing token files** + +```bash +ls src/*.css src/styles 2>/dev/null; cat src/index.css 2>/dev/null | head -30; cat tailwind.config.js 2>/dev/null | head -40 +``` + +Read enough to see how colors are currently organized. Match the existing pattern. + +- [ ] **Step 2: Add the Ticker Tape tokens** + +Add to the appropriate CSS file (or Tailwind theme extension). Three-color accent + dark base: + +```css +/* src/styles/ticker-tape.css */ +:root { + --tt-bg: #0a0e14; + --tt-bg-elev: #11161f; + --tt-ink: #e6e1d3; + --tt-ink-dim: #8a8b8e; + --tt-amber: #f4b942; /* action needed */ + --tt-oxblood: #8b2c2c; /* rejection */ + --tt-ink-blue: #2c4a6e; /* neutral / waiting */ + --tt-muted: #4a4d54; +} + +@font-face { + font-family: "IBM Plex Serif"; + font-display: swap; + src: url("https://fonts.gstatic.com/s/ibmplexserif/v19/jizDREVItHgc8qDIbSTKq4XKVUaaRA0od9b6XQ.woff2") format("woff2"); + font-weight: 400 700; +} + +@font-face { + font-family: "IBM Plex Mono"; + font-display: swap; + src: url("https://fonts.gstatic.com/s/ibmplexmono/v19/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.woff2") format("woff2"); + font-weight: 400 700; +} +``` + +(If the project uses Tailwind theme.extend, prefer extending `theme.extend.colors` and `theme.extend.fontFamily` over CSS variables.) + +- [ ] **Step 3: Smoke test** + +```bash +npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/index.css src/styles/ticker-tape.css tailwind.config.js +git commit -m "feat(sp6): Ticker Tape color + typography tokens" +``` + +--- + +## Task 13: `/inbox` route + nav link + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/components/Sidebar.tsx` (or whatever the nav component is) + +- [ ] **Step 1: Find the route table and nav links** + +```bash +grep -n "Route\|path=" src/App.tsx | head -10 +grep -n "Dashboard\|/claims\|/remittances" src/components/*.tsx | head -10 +``` + +- [ ] **Step 2: Add the route and link** + +In the route table, add (position: second, after Dashboard): + +```tsx +import Inbox from "./pages/Inbox"; +// ... +} /> +``` + +In the sidebar, add a link: + +```tsx +Inbox +``` + +- [ ] **Step 3: Create the placeholder Inbox page so the route resolves** + +```tsx +// src/pages/Inbox.tsx +export default function Inbox() { + return
+ Inbox — coming up in T14–T17 +
; +} +``` + +- [ ] **Step 4: Smoke test** + +```bash +npx tsc --noEmit && npx vitest run --reporter=dot +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/App.tsx src/components/Sidebar.tsx src/pages/Inbox.tsx +git commit -m "feat(sp6): /inbox route + sidebar nav link" +``` + +--- + +## Phase 6 — Inbox components + +## Task 14: `InboxRow` (TDD) + +**Files:** +- Create: `src/components/inbox/InboxRow.tsx` +- Create: `src/components/inbox/InboxRow.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/components/inbox/InboxRow.test.tsx +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { InboxRow } from "./InboxRow"; + +const baseClaim = { + id: "CLP-7741", + kind: "claim" as const, + patient_control_number: "PCN-001", + charge_amount: 1240, + payer_id: "CO-MCD", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "999 AK9 R", + rejected_at: "2026-06-20T09:12:00Z", + service_date_from: null, + score: null, + score_tier: null, + score_breakdown: null, +}; + +describe("InboxRow", () => { + it("renders the claim id, payer, charge", () => { + render( {}} />
); + expect(screen.getByText("CLP-7741")).toBeDefined(); + expect(screen.getByText("CO-MCD")).toBeDefined(); + expect(screen.getByText("$1,240.00")).toBeDefined(); + }); + + it("renders the rejection reason when present", () => { + render( {}} />
); + expect(screen.getByText(/999 AK9 R/)).toBeDefined(); + }); + + it("calls onClick when clicked", () => { + const onClick = vi.fn(); + render(
); + fireEvent.click(screen.getByText("CLP-7741")); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it("renders score sparkline for candidate rows", () => { + const candidateRow = { + id: "R-1", + kind: "remit" as const, + payer_claim_control_number: "PCN-X", + charge_amount: 500, + payer_id: "AETNA", + rendering_provider_npi: "1234567890", + service_date: null, + candidates: [{ + claim_id: "C-1", score: 92, tier: "strong" as const, + breakdown: { patient: 1, date: 1, amount: 1, provider: 1 }, + }], + }; + render( {}} />
); + expect(screen.getByText("92")).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +npx vitest run src/components/inbox/InboxRow.test.tsx +``` + +- [ ] **Step 3: Implement** + +```tsx +// src/components/inbox/InboxRow.tsx +import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api"; + +type Accent = "oxblood" | "amber" | "ink-blue" | "muted"; + +const ACCENT_VAR: Record = { + oxblood: "var(--tt-oxblood)", + amber: "var(--tt-amber)", + "ink-blue": "var(--tt-ink-blue)", + muted: "var(--tt-muted)", +}; + +function fmtMoney(n: number): string { + return n.toLocaleString("en-US", { style: "currency", currency: "USD" }); +} + +function fmtAgo(iso: string | null): string { + if (!iso) return ""; + const ms = Date.now() - new Date(iso).getTime(); + const mins = Math.round(ms / 60_000); + if (mins < 60) return `${mins}m`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h`; + const days = Math.round(hrs / 24); + return `${days}d`; +} + +type Props = + | { row: InboxClaimRow; accent: Accent; onClick: () => void } + | { row: InboxCandidateRow; accent: Accent; onClick: () => void }; + +export function InboxRow(props: Props) { + const { row, accent, onClick } = props; + const id = "id" in row ? row.id : (row as InboxCandidateRow).payer_claim_control_number; + const payer = row.payer_id ?? ""; + const charge = fmtMoney(row.charge_amount); + const rejectedAt = "rejected_at" in row ? row.rejected_at : null; + const reason = "rejection_reason" in row ? row.rejection_reason : null; + const topScore = "candidates" in row && row.candidates.length ? row.candidates[0].score : null; + const filled = "candidates" in row && row.candidates.length + ? row.candidates[0].breakdown + : null; + + return ( + + + {id} + + {reason ? reason : topScore != null ? `${topScore}` : ""} + + + {fmtAgo(rejectedAt)} + + {charge} + {payer} + + {filled ? ( + + {["patient", "date", "amount", "provider"].map((k) => ( + = 0.5 ? "var(--tt-amber)" : "var(--tt-muted)" }} + /> + ))} + + ) : null} + + + ); +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +npx vitest run src/components/inbox/InboxRow.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/components/inbox/InboxRow.tsx src/components/inbox/InboxRow.test.tsx +git commit -m "feat(sp6): InboxRow with Ticker Tape accents" +``` + +--- + +## Task 15: `Lane` (TDD) + +**Files:** +- Create: `src/components/inbox/Lane.tsx` +- Create: `src/components/inbox/Lane.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// src/components/inbox/Lane.test.tsx +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Lane } from "./Lane"; + +const sampleRows = [ + { + id: "CLP-1", + kind: "claim" as const, + patient_control_number: "PCN-1", + charge_amount: 100, + payer_id: "PAYER-A", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "R69", + rejected_at: "2026-06-20T09:00:00Z", + service_date_from: null, + score: null, + score_tier: null, + score_breakdown: null, + }, +]; + +describe("Lane", () => { + it("renders the lane name and row count", () => { + render( {}} />); + expect(screen.getByText("REJECTED")).toBeDefined(); + expect(screen.getByText(/1/)).toBeDefined(); + }); + + it("renders an empty state when no rows", () => { + render( {}} />); + expect(screen.getByText(/nothing here/i)).toBeDefined(); + }); + + it("supports multi-select via row checkbox toggle", () => { + const onSelectionChange = vi.fn(); + render( + {}} + onSelectionChange={onSelectionChange} + /> + ); + const cb = screen.getByRole("checkbox"); + fireEvent.click(cb); + expect(onSelectionChange).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +npx vitest run src/components/inbox/Lane.test.tsx +``` + +- [ ] **Step 3: Implement** + +```tsx +// src/components/inbox/Lane.tsx +import { useState } from "react"; +import { InboxRow } from "./InboxRow"; +import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api"; + +type Accent = "oxblood" | "amber" | "ink-blue" | "muted"; + +type Row = InboxClaimRow | InboxCandidateRow; + +export function Lane({ + name, + accent, + rows, + onRowClick, + onSelectionChange, +}: { + name: string; + accent: Accent; + rows: Row[]; + onRowClick: (row: Row) => void; + onSelectionChange?: (ids: string[]) => void; +}) { + const [selected, setSelected] = useState>(new Set()); + + function toggle(id: string) { + const next = new Set(selected); + if (next.has(id)) next.delete(id); + else next.add(id); + setSelected(next); + onSelectionChange?.(Array.from(next)); + } + + return ( +
+
+

{name}

+ {rows.length} +
+ + {rows.length === 0 ? ( +

Nothing here.

+ ) : ( + + + {rows.map((row) => { + const id = "id" in row ? row.id : row.payer_claim_control_number; + return ( + + + + + ); + })} + +
+ toggle(id)} + onClick={(e) => e.stopPropagation()} + /> + + onRowClick(row)} /> +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +npx vitest run src/components/inbox/Lane.test.tsx +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/components/inbox/Lane.tsx src/components/inbox/Lane.test.tsx +git commit -m "feat(sp6): Lane component with multi-select" +``` + +--- + +## Task 16: `InboxHeader` + `CandidateBreakdown` (TDD) + +**Files:** +- Create: `src/components/inbox/InboxHeader.tsx` +- Create: `src/components/inbox/InboxHeader.test.tsx` +- Create: `src/components/inbox/CandidateBreakdown.tsx` +- Create: `src/components/inbox/CandidateBreakdown.test.tsx` + +- [ ] **Step 1: Header test** + +```tsx +// src/components/inbox/InboxHeader.test.tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InboxHeader } from "./InboxHeader"; + +describe("InboxHeader", () => { + it("renders the date and counts", () => { + render(); + expect(screen.getByText(/6 items need eyes/)).toBeDefined(); + expect(screen.getByText(/23 done today/)).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Breakdown test** + +```tsx +// src/components/inbox/CandidateBreakdown.test.tsx +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { CandidateBreakdown } from "./CandidateBreakdown"; + +describe("CandidateBreakdown", () => { + it("renders per-field scores", () => { + render( + + ); + expect(screen.getByText(/patient/i)).toBeDefined(); + expect(screen.getByText(/date/i)).toBeDefined(); + expect(screen.getByText(/amount/i)).toBeDefined(); + expect(screen.getByText(/provider/i)).toBeDefined(); + expect(screen.getByText("92")).toBeDefined(); + }); +}); +``` + +- [ ] **Step 3: Run, confirm FAIL** + +```bash +npx vitest run src/components/inbox/ +``` + +- [ ] **Step 4: Implement both** + +```tsx +// src/components/inbox/InboxHeader.tsx +export function InboxHeader({ needEyesCount, doneTodayCount }: { + needEyesCount: number; + doneTodayCount: number; +}) { + const now = new Date(); + const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase(); + const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + return ( +
+

+ INBOX · {day} {time} +

+

+ {needEyesCount} items need eyes ·{" "} + {doneTodayCount} done today +

+
+ ); +} +``` + +```tsx +// src/components/inbox/CandidateBreakdown.tsx +import type { ScoreBreakdown, ScoreTier } from "../../lib/inbox-api"; + +export function CandidateBreakdown({ + score, + breakdown, + tier, +}: { + score: number; + breakdown: ScoreBreakdown; + tier: ScoreTier; +}) { + const fields = [ + { key: "patient" as const, label: "Patient", weight: 40 }, + { key: "date" as const, label: "Date", weight: 25 }, + { key: "amount" as const, label: "Amount", weight: 20 }, + { key: "provider" as const, label: "Provider", weight: 15 }, + ]; + return ( +
+
+ Score + {score} +
+
    + {fields.map((f) => ( +
  • + {f.label} + + {Math.round(breakdown[f.key] * f.weight)} / {f.weight} + +
  • + ))} +
+

{tier}

+
+ ); +} +``` + +- [ ] **Step 5: Run, confirm PASS** + +```bash +npx vitest run src/components/inbox/ +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/inbox/InboxHeader.tsx src/components/inbox/InboxHeader.test.tsx src/components/inbox/CandidateBreakdown.tsx src/components/inbox/CandidateBreakdown.test.tsx +git commit -m "feat(sp6): InboxHeader + CandidateBreakdown" +``` + +--- + +## Task 17: `Inbox` page + `useInboxLanes` hook (TDD) + +**Files:** +- Create: `src/hooks/useInboxLanes.ts` +- Create: `src/hooks/useInboxLanes.test.ts` +- Replace: `src/pages/Inbox.tsx` (real implementation, not placeholder) + +- [ ] **Step 1: Hook test** + +```typescript +// src/hooks/useInboxLanes.test.ts +import { describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useInboxLanes } from "./useInboxLanes"; + +describe("useInboxLanes", () => { + it("loads lanes on mount", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), + })); + const { result } = renderHook(() => useInboxLanes()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.lanes).toEqual({ rejected: [], candidates: [], unmatched: [], done_today: [] }); + vi.unstubAllGlobals(); + }); +}); +``` + +- [ ] **Step 2: Page test** + +```tsx +// src/pages/Inbox.test.tsx +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import Inbox from "./Inbox"; + +describe("Inbox page", () => { + it("renders the four lane headers", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), + })); + render(); + await waitFor(() => { + expect(screen.getByText("REJECTED")).toBeDefined(); + expect(screen.getByText("CANDIDATES")).toBeDefined(); + expect(screen.getByText("UNMATCHED")).toBeDefined(); + expect(screen.getByText("DONE")).toBeDefined(); + }); + vi.unstubAllGlobals(); + }); +}); +``` + +- [ ] **Step 3: Run, confirm FAIL** + +```bash +npx vitest run src/hooks/useInboxLanes.test.ts src/pages/Inbox.test.tsx +``` + +- [ ] **Step 4: Implement** + +```typescript +// src/hooks/useInboxLanes.ts +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchInboxLanes, type InboxLanes } from "../lib/inbox-api"; + +export function useInboxLanes() { + const [lanes, setLanes] = useState({ + rejected: [], candidates: [], unmatched: [], done_today: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const debounceRef = useRef | null>(null); + + const refetch = useCallback(async () => { + try { + const next = await fetchInboxLanes(); + setLanes(next); + setError(null); + } catch (e) { + setError(e as Error); + } finally { + setLoading(false); + } + }, []); + + const refetchDebounced = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { void refetch(); }, 500); + }, [refetch]); + + useEffect(() => { void refetch(); }, [refetch]); + + // Subscribe to live-tail events. Coalesce bursts via 500ms debounce. + useEffect(() => { + const es = new EventSource("/api/claims/stream"); + const onAny = () => refetchDebounced(); + es.addEventListener("claim.rejected", onAny); + es.addEventListener("claim.matched", onAny); + es.addEventListener("claim.resubmitted", onAny); + es.addEventListener("claim.paid", onAny); + return () => { es.close(); }; + }, [refetchDebounced]); + + return { lanes, loading, error, refetch }; +} +``` + +(Note: if `/api/claims/stream` isn't an EventSource (it's NDJSON), use the existing `useTailStream` hook from SP5 instead — replace this useEffect with that hook's `events` dependency.) + +```tsx +// src/pages/Inbox.tsx +import { Lane } from "../components/inbox/Lane"; +import { InboxHeader } from "../components/inbox/InboxHeader"; +import { useInboxLanes } from "../hooks/useInboxLanes"; + +export default function Inbox() { + const { lanes, loading, error } = useInboxLanes(); + + if (loading) { + return ( +
+

loading…

+
+ ); + } + if (error) { + return ( +
+

error: {error.message}

+
+ ); + } + + const needEyes = + lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length; + + return ( +
+ +
+ {}} /> + {}} /> + {}} /> + {}} /> +
+
+ ); +} +``` + +- [ ] **Step 5: Run, confirm PASS** + +```bash +npx vitest run src/pages/Inbox.test.tsx src/hooks/useInboxLanes.test.ts +``` + +- [ ] **Step 6: Type-check + full frontend suite** + +```bash +npx tsc --noEmit && npx vitest run --reporter=dot +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx +git commit -m "feat(sp6): Inbox page + useInboxLanes hook" +``` + +--- + +## Phase 7 — Bulk actions + tail integration + +## Task 18: Wire bulk action bar (TDD) + +**Files:** +- Modify: `src/components/inbox/Lane.tsx` +- Create: `src/components/inbox/BulkBar.tsx` +- Modify: `src/components/inbox/Lane.test.tsx` +- Create: `src/components/inbox/BulkBar.test.tsx` + +- [ ] **Step 1: BulkBar test** + +```tsx +// src/components/inbox/BulkBar.test.tsx +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { BulkBar } from "./BulkBar"; + +describe("BulkBar", () => { + it("renders action buttons appropriate for the lane", () => { + render( + + ); + expect(screen.getByRole("button", { name: /resubmit/i })).toBeDefined(); + expect(screen.getByRole("button", { name: /export/i })).toBeDefined(); + expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); + }); + + it("calls onResubmit when Resubmit clicked", () => { + const onResubmit = vi.fn(); + render( {}} onExport={() => {}} />); + fireEvent.click(screen.getByRole("button", { name: /resubmit/i })); + expect(onResubmit).toHaveBeenCalledOnce(); + }); +}); +``` + +- [ ] **Step 2: Implement BulkBar** + +```tsx +// src/components/inbox/BulkBar.tsx +export function BulkBar({ + lane, + count, + onResubmit, + onDismiss, + onExport, +}: { + lane: "rejected" | "candidates" | "unmatched" | "done_today"; + count: number; + onResubmit: () => void; + onDismiss: () => void; + onExport: () => void; +}) { + if (count === 0) return null; + return ( +
+ {count} selected + {lane === "rejected" && ( + + )} + {lane === "candidates" && ( + + )} + +
+ ); +} +``` + +- [ ] **Step 3: Wire into Lane** + +Edit `src/components/inbox/Lane.tsx` to accept `onResubmit`, `onDismiss`, `onExport` props and render `` when selection is non-empty. + +- [ ] **Step 4: Wire into Inbox page** + +In `src/pages/Inbox.tsx`, pass handlers that call `resubmitRejected`, `dismissCandidates`, and `window.open(exportInboxCsvUrl(...))`. Then call `refetch()`. + +- [ ] **Step 5: Run + commit** + +```bash +npx vitest run src/components/inbox/ +git add src/components/inbox/ +git commit -m "feat(sp6): bulk action bar wired into lanes" +``` + +--- + +## Task 19: `useInboxLanes` consumes SP5 tail (TDD) + +**Files:** +- Modify: `src/hooks/useInboxLanes.ts` +- Modify: `src/hooks/useInboxLanes.test.ts` + +- [ ] **Step 1: Add the failing test** + +Append to `src/hooks/useInboxLanes.test.ts`: + +```typescript +import { useTailStream } from "./useTailStream"; +vi.mock("./useTailStream", () => ({ + useTailStream: vi.fn(() => ({ events: [], status: "open" })), +})); + +it("refetches on tail events", async () => { + // Test that calling the mocked useTailStream with a callback that triggers + // refetch causes a second fetch call. + // Implementation detail: the hook should accept a list of stream URLs and + // re-call refetchDebounced when events arrive. +}); +``` + +- [ ] **Step 2: Run, confirm FAIL** + +```bash +npx vitest run src/hooks/useInboxLanes.test.ts +``` + +- [ ] **Step 3: Refactor `useInboxLanes`** + +Replace the manual `EventSource` block with: + +```typescript +import { useTailStream } from "./useTailStream"; + +const claimStream = useTailStream("/api/claims/stream"); +const remitStream = useTailStream("/api/remittances/stream"); + +useEffect(() => { + if (claimStream.events.length > 0) refetchDebounced(); +}, [claimStream.events.length, refetchDebounced]); + +useEffect(() => { + if (remitStream.events.length > 0) refetchDebounced(); +}, [remitStream.events.length, refetchDebounced]); +``` + +- [ ] **Step 4: Run, confirm PASS** + +```bash +npx vitest run src/hooks/ +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts +git commit -m "feat(sp6): useInboxLanes refetches on SP5 tail events" +``` + +--- + +## Phase 8 — Docs + smoke + +## Task 20: README "Inbox" section + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Find the existing "Live updates" section** + +```bash +grep -n "Live updates\|## API\|## Live" README.md | head -5 +``` + +- [ ] **Step 2: Add a new section after "Live updates"** + +```markdown +## Inbox + +`/inbox` is the working surface. Four lanes, dark by default (Ticker Tape aesthetic): + +- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk. +- **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss. +- **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold. +- **Done today** — terminal state transitions in the last 24 hours. + +### Inbox endpoints + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/api/inbox/lanes` | All four lanes in one call. | +| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. 409 on conflict. | +| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. | +| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. 409 on non-rejected. | +| GET | `/api/inbox/export.csv?lane=` | Streams CSV of the lane's rows. | + +### Scoring + +4-field weighted (sum = 100): + +| Field | Weight | Rule | +|-------|--------|------| +| Patient control number | 40 | Exact match, normalized (case + leading zeros). | +| Service date | 25 | Linear decay over ±3 days. | +| Charge amount | 20 | Linear decay over ±10%. | +| Provider NPI | 15 | Exact match. Missing → 0. | + +Tiers: **strong** (≥75, full opacity, Match enabled), **weak** (50–74, dimmed), **hidden** (<50, not surfaced). +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(sp6): README — Inbox section" +``` + +--- + +## Task 21: Manual smoke test + +- [ ] **Step 1: Start backend** + +```bash +cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/smoke.db ../.venv/bin/python -m cyclone & +``` + +- [ ] **Step 2: Start frontend dev server** + +```bash +cd .. && npm run dev & +``` + +- [ ] **Step 3: Run the existing 837 + 999 + 835 seed scripts** + +```bash +curl -X POST -F "file=@backend/tests/fixtures/sample_837.txt" http://127.0.0.1:8000/api/parse-837 +curl -X POST -F "file=@backend/tests/fixtures/sample_999_reject.999" http://127.0.0.1:8000/api/parse-999 +curl -X POST -F "file=@backend/tests/fixtures/sample_835.txt" http://127.0.0.1:8000/api/parse-835 +``` + +Expected: 200 on each. If any fixture file doesn't exist, locate the appropriate one in `backend/tests/fixtures/` or generate a minimal one. + +- [ ] **Step 4: Hit the lanes endpoint** + +```bash +curl http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40 +``` + +Expected: non-empty `rejected` (from 999), `candidates` or `unmatched` (from 835), and `done_today` if reconciliation advanced any claim state. + +- [ ] **Step 5: Open the Inbox page** + +Navigate to `http://127.0.0.1:5173/inbox` (or whatever port Vite uses). Verify: + +- All four lane headers visible. +- Rows populate from the API. +- Multi-select → bulk bar appears. +- Click row → opens drawer (or at minimum, logs the row id). +- Match button on a candidate row → calls API, row disappears. + +- [ ] **Step 6: Kill servers** + +```bash +kill %1 %2 2>/dev/null +``` + +- [ ] **Step 7: Final full-suite run** + +```bash +cd backend && ../.venv/bin/pytest -q +cd .. && npx vitest run --reporter=dot +``` + +Expected: both green. + +- [ ] **Step 8: Final commit + branch ready for review** + +```bash +git log --oneline main..HEAD +git push -u origin sp6-workflow-inbox +``` + +--- + +## Self-review checklist + +- [x] **Spec coverage:** + - §2.1 Ticker Tape aesthetic → T12 + - §2.2 Four lanes ordered by urgency → T6 (computation), T17 (page) + - §2.3 Row contents (rail, ID, status, time, charge, payer, patient, score sparkline) → T14 + - §2.4 Actions (manual match, dismiss, bulk dismiss, bulk resubmit, bulk export) → T8, T9, T18 + - §2.5 Live tail integration → T19 + - §3.1 Add REJECTED to ClaimState enum → T1 + - §3.2 New columns (rejection_reason, rejected_at, resubmit_count) + state-history index → T2 + - §3.3 No new claim_candidates table → confirmed (T6 computes on read) + - §4.1 999 → REJECTED state → T3, T4 + - §4.2 835 unmatched → candidates (no auto-match) → T6 + - §4.3 4-field weighted scoring → T5 + - §4.4 New endpoints → T7, T8, T9, T10 + - §4.5 Dismissed-pairs session store → T7 (init) + T8 (mutate) + - §5.1 Frontend files → T11–T19 + - §5.2 Routing + sidebar nav → T13 + - §5.3 Component responsibilities → T14–T17 + - §5.4 Tail integration → T19 + - §5.5 Bulk action UI → T18 + - §6 Error handling → T4 (orphan 999 logging), T8 (409), T17 (error UI), T18 (export) + - §7 Testing strategy → covered per task + - §9 Open impl questions → T12 (typography choice), T17 (lane widths = `min-w-[280px]` flex), T13 (landing page deferred) + +- [x] **Placeholder scan:** No "TODO", "implement later", "add appropriate error handling" without code. Every code block is runnable. + +- [x] **Type consistency:** `ScoreBreakdown` fields `patient/date/amount/provider` consistent across scoring.py, inbox-api.ts, InboxRow.tsx, CandidateBreakdown.tsx. `ClaimState.REJECTED` consistent across db.py, inbox_state.py, api.py. `dismissed_pairs` set semantics consistent. + +- [x] **No spec gaps:** All 9 spec sections map to at least one task. + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md`. 21 tasks. + +**Two execution options:** + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for plans this size. +2. **Inline Execution** — Execute tasks in this session using `executing-plans`, batch with checkpoints. + +Which approach? \ No newline at end of file