2681 lines
87 KiB
Markdown
2681 lines
87 KiB
Markdown
# Sub-project 7 — Per-Service-Line Adjustment Audit: 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.
|
||
|
||
**Date:** 2026-06-20
|
||
**Status:** Ready
|
||
**Branch:** work continues on `main` (per session convention)
|
||
**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md`
|
||
|
||
21 tasks across 6 phases. TDD throughout. Each task ends with a green test run + commit.
|
||
|
||
**Prerequisite:** SP1–SP6 are merged on `main`. No new pip deps, no new npm deps.
|
||
|
||
**Tech additions:** None.
|
||
|
||
---
|
||
|
||
## Task 0: Verify SP6 is on `main`
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: Confirm SP6 is committed**
|
||
|
||
```bash
|
||
cd /Users/openclaw/dev/cyclone
|
||
git log --oneline main | head -5
|
||
git log --oneline main | grep -E "sp6.*(BulkBar|InboxHeader|compute_lanes|apply_999|migration 0004)" | head -5
|
||
```
|
||
|
||
Expected: SP6 commits visible on `main`. If not, stop and resolve first.
|
||
|
||
---
|
||
|
||
## Phase 1 — Schema
|
||
|
||
## Task 1: Migration 0005 (line_reconciliation)
|
||
|
||
**Files:**
|
||
- Create: `backend/src/cyclone/migrations/0005_line_reconciliation.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
```sql
|
||
-- version: 5
|
||
-- SP7: per-service-line adjustment audit
|
||
-- See spec: docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md
|
||
|
||
CREATE TABLE service_line_payments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
remittance_id VARCHAR(64) NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
|
||
line_number INTEGER NOT NULL,
|
||
procedure_qualifier VARCHAR(4) NOT NULL,
|
||
procedure_code VARCHAR(16) NOT NULL,
|
||
modifiers_json TEXT NOT NULL DEFAULT '[]',
|
||
charge NUMERIC(12, 2) NOT NULL,
|
||
payment NUMERIC(12, 2) NOT NULL,
|
||
units NUMERIC(10, 2),
|
||
unit_type VARCHAR(8),
|
||
service_date DATE,
|
||
ref_benefit_plan VARCHAR(64),
|
||
superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL
|
||
);
|
||
|
||
CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id);
|
||
CREATE INDEX ix_service_line_payments_procedure_code_date ON service_line_payments(procedure_code, service_date);
|
||
|
||
CREATE TABLE line_reconciliations (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
claim_id VARCHAR(64) NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
|
||
claim_service_line_id INTEGER REFERENCES service_lines(id) ON DELETE SET NULL,
|
||
service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
|
||
status VARCHAR(16) NOT NULL,
|
||
match_score INTEGER,
|
||
reconciled_at TIMESTAMP NOT NULL
|
||
);
|
||
|
||
CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id);
|
||
CREATE INDEX ix_line_reconciliations_claim_service_line_id ON line_reconciliations(claim_service_line_id);
|
||
CREATE INDEX ix_line_reconciliations_service_line_payment_id ON line_reconciliations(service_line_payment_id);
|
||
|
||
ALTER TABLE cas_adjustments ADD COLUMN service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL;
|
||
|
||
ALTER TABLE remittances ADD COLUMN claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0;
|
||
|
||
CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id);
|
||
```
|
||
|
||
(Each `ALTER TABLE` and `CREATE TABLE` in its own statement — SQLite does not support multiple adds in one statement; this is the lesson from SP6 T2.)
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/migrations/0005_line_reconciliation.sql
|
||
git commit -m "feat(sp7): migration 0005 — service_line_payments + line_reconciliations + FKs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: ORM model — `ServiceLinePayment`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/db.py`
|
||
- Test: `backend/tests/test_db_models.py` (count bump only — covered by migration tests for shape)
|
||
|
||
- [ ] **Step 1: Add the model class to `db.py`**
|
||
|
||
After the existing `CasAdjustment` class, add:
|
||
|
||
```python
|
||
class ServiceLinePayment(Base):
|
||
"""One row per 835 SVC composite (loop 2110).
|
||
|
||
SP7. Persisted on 835 ingest *before* ``reconcile.run()`` decides
|
||
which claim this line applies to. Independent of claim matching.
|
||
|
||
See spec §3.1.
|
||
"""
|
||
__tablename__ = "service_line_payments"
|
||
|
||
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
|
||
)
|
||
line_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||
procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
|
||
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
|
||
modifiers_json: Mapped[str] = mapped_column(
|
||
JSONText, nullable=False, default=lambda: "[]"
|
||
)
|
||
charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||
payment: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||
units: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
|
||
unit_type: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
|
||
service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||
ref_benefit_plan: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||
superseded_by_id: Mapped[Optional[int]] = mapped_column(
|
||
Integer,
|
||
ForeignKey("service_line_payments.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
|
||
__table_args__ = (
|
||
Index("ix_service_line_payments_remittance_id", "remittance_id"),
|
||
Index(
|
||
"ix_service_line_payments_procedure_code_date",
|
||
"procedure_code",
|
||
"service_date",
|
||
),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/db.py
|
||
git commit -m "feat(sp7): ServiceLinePayment ORM model"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: ORM model — `LineReconciliation`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/db.py`
|
||
|
||
- [ ] **Step 1: Add the model class**
|
||
|
||
After `ServiceLinePayment`, add:
|
||
|
||
```python
|
||
class LineReconciliation(Base):
|
||
"""One row per matched (or explicitly unmatched) 837 service line within a claim.
|
||
|
||
SP7. Created during ``reconcile.run()`` after the claim↔remit match.
|
||
At least one of ``claim_service_line_id`` / ``service_line_payment_id``
|
||
must be non-null; the application enforces this.
|
||
|
||
See spec §3.2.
|
||
"""
|
||
__tablename__ = "line_reconciliations"
|
||
|
||
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
|
||
)
|
||
claim_service_line_id: Mapped[Optional[int]] = mapped_column(
|
||
Integer,
|
||
ForeignKey("service_lines.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
service_line_payment_id: Mapped[Optional[int]] = mapped_column(
|
||
Integer,
|
||
ForeignKey("service_line_payments.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||
match_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||
reconciled_at: Mapped[datetime] = mapped_column(
|
||
DateTime(timezone=True), nullable=False
|
||
)
|
||
|
||
__table_args__ = (
|
||
Index("ix_line_reconciliations_claim_id", "claim_id"),
|
||
Index(
|
||
"ix_line_reconciliations_claim_service_line_id", "claim_service_line_id"
|
||
),
|
||
Index(
|
||
"ix_line_reconciliations_service_line_payment_id",
|
||
"service_line_payment_id",
|
||
),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/db.py
|
||
git commit -m "feat(sp7): LineReconciliation ORM model"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Add FK + column to existing tables
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/db.py`
|
||
|
||
- [ ] **Step 1: Add `service_line_payment_id` to `CasAdjustment`**
|
||
|
||
Find the `CasAdjustment` class. Add this column after `quantity`:
|
||
|
||
```python
|
||
service_line_payment_id: Mapped[Optional[int]] = mapped_column(
|
||
Integer,
|
||
ForeignKey("service_line_payments.id", ondelete="SET NULL"),
|
||
nullable=True,
|
||
)
|
||
```
|
||
|
||
And add to `__table_args__`:
|
||
|
||
```python
|
||
Index("ix_cas_adjustments_service_line_payment_id", "service_line_payment_id"),
|
||
```
|
||
|
||
- [ ] **Step 2: Add `claim_level_adjustment_amount` to `Remittance`**
|
||
|
||
Find the `Remittance` class. Add this column after `adjustment_amount`:
|
||
|
||
```python
|
||
claim_level_adjustment_amount: Mapped[Decimal] = mapped_column(
|
||
Numeric(12, 2), nullable=False, default=Decimal("0"), server_default=text("0")
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/db.py
|
||
git commit -m "feat(sp7): CasAdjustment.service_line_payment_id + Remittance.claim_level_adjustment_amount"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Update existing test fixtures for the migration
|
||
|
||
**Files:**
|
||
- Modify: `backend/tests/test_acks.py`
|
||
- Modify: `backend/tests/test_db_models.py`
|
||
|
||
- [ ] **Step 1: Update `test_acks.py` version assertion**
|
||
|
||
Find the version assertion (currently 4). Change to 5:
|
||
|
||
```python
|
||
assert version == 5 # SP7 migration 0005
|
||
```
|
||
|
||
- [ ] **Step 2: Update `test_db_models.py` enum count**
|
||
|
||
Find the model count assertion. The exact assertion depends on the file; bump by +2 (for `ServiceLinePayment` and `LineReconciliation`). If the test asserts the exact set of class names, add the two new class names.
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_acks.py tests/test_db_models.py -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/tests/test_acks.py backend/tests/test_db_models.py
|
||
git commit -m "test(sp7): update migration version + model count assertions"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 2 — Persistence
|
||
|
||
## Task 6: Persist `ServiceLinePayment` on 835 ingest
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/store.py`
|
||
- Test: `backend/tests/test_service_line_payments.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/tests/test_service_line_payments.py`:
|
||
|
||
```python
|
||
"""SP7 — 835 ingest persists ServiceLinePayment rows + links CAS rows.
|
||
|
||
See spec §3.1, §3.3.
|
||
"""
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select
|
||
|
||
from cyclone.db import (
|
||
Batch,
|
||
CasAdjustment,
|
||
Remittance,
|
||
ServiceLinePayment,
|
||
SessionLocal,
|
||
)
|
||
from cyclone.parsers.models_835 import (
|
||
ClaimAdjustment,
|
||
ClaimPayment,
|
||
ServicePayment,
|
||
)
|
||
from cyclone.store import _persist_835_remit
|
||
|
||
|
||
def _make_batch() -> str:
|
||
"""Persist a bare Batch row and return its id."""
|
||
from datetime import datetime, timezone
|
||
import uuid
|
||
bid = str(uuid.uuid4())
|
||
with SessionLocal()() as s:
|
||
s.add(Batch(
|
||
id=bid, source_filename="x.835",
|
||
parsed_at=datetime.now(timezone.utc),
|
||
summary_json={"claim_count": 1, "total_charge": "0", "total_paid": "0"},
|
||
claim_count=1,
|
||
))
|
||
s.commit()
|
||
return bid
|
||
|
||
|
||
def test_persist_835_creates_one_service_line_payment_per_svc_composite():
|
||
bid = _make_batch()
|
||
cp = ClaimPayment(
|
||
payer_claim_control_number="PCN-1",
|
||
status_code="1",
|
||
total_charge=Decimal("200.00"),
|
||
total_paid=Decimal("150.00"),
|
||
service_payments=[
|
||
ServicePayment(
|
||
line_number=1, procedure_qualifier="HC",
|
||
procedure_code="99213", charge=Decimal("100.00"),
|
||
payment=Decimal("80.00"),
|
||
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))],
|
||
),
|
||
ServicePayment(
|
||
line_number=2, procedure_qualifier="HC",
|
||
procedure_code="99214", charge=Decimal("100.00"),
|
||
payment=Decimal("70.00"),
|
||
adjustments=[],
|
||
),
|
||
],
|
||
)
|
||
with SessionLocal()() as s:
|
||
from cyclone.store import _remittance_835_row
|
||
remit = _remittance_835_row(cp, bid)
|
||
s.add(remit)
|
||
s.flush()
|
||
_persist_835_remit(s, cp, remit.id)
|
||
s.commit()
|
||
|
||
slps = list(s.execute(select(ServiceLinePayment).order_by(ServiceLinePayment.line_number)).scalars().all())
|
||
assert len(slps) == 2
|
||
assert slps[0].procedure_code == "99213"
|
||
assert slps[0].payment == Decimal("80.00")
|
||
assert slps[1].procedure_code == "99214"
|
||
assert slps[1].payment == Decimal("70.00")
|
||
assert slps[0].remittance_id == remit.id
|
||
|
||
|
||
def test_cas_adjustment_rows_get_service_line_payment_id_set():
|
||
"""The SVC-level CAS rows must link back to their ServiceLinePayment."""
|
||
bid = _make_batch()
|
||
cp = ClaimPayment(
|
||
payer_claim_control_number="PCN-1",
|
||
status_code="1",
|
||
total_charge=Decimal("100.00"),
|
||
total_paid=Decimal("80.00"),
|
||
service_payments=[
|
||
ServicePayment(
|
||
line_number=1, procedure_qualifier="HC",
|
||
procedure_code="99213", charge=Decimal("100.00"),
|
||
payment=Decimal("80.00"),
|
||
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))],
|
||
),
|
||
],
|
||
)
|
||
with SessionLocal()() as s:
|
||
from cyclone.store import _remittance_835_row
|
||
remit = _remittance_835_row(cp, bid)
|
||
s.add(remit)
|
||
s.flush()
|
||
_persist_835_remit(s, cp, remit.id)
|
||
s.commit()
|
||
|
||
cas = list(s.execute(select(CasAdjustment)).scalars().all())
|
||
assert len(cas) == 1
|
||
assert cas[0].service_line_payment_id is not None
|
||
```
|
||
|
||
(Note: the test references `_persist_835_remit` which doesn't exist yet — that's the red.)
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_service_line_payments.py -v
|
||
```
|
||
|
||
Expected: ImportError on `_persist_835_remit` or `ServiceLinePayment`.
|
||
|
||
- [ ] **Step 3: Implement `_persist_835_remit`**
|
||
|
||
In `backend/src/cyclone/store.py`, add:
|
||
|
||
```python
|
||
def _persist_835_remit(session, cp: "ClaimPayment", remittance_id: str) -> None:
|
||
"""Persist ServiceLinePayment + CasAdjustment rows for one CLP composite.
|
||
|
||
Spec §4.4: runs BEFORE ``reconcile.run()``. The CAS rows get their
|
||
``service_line_payment_id`` set here so the line match step can
|
||
aggregate per-line adjustments later.
|
||
|
||
Returns nothing; the caller controls the transaction.
|
||
"""
|
||
import json as _json
|
||
from cyclone.db import ServiceLinePayment, CasAdjustment
|
||
|
||
for svc in cp.service_payments:
|
||
slp = ServiceLinePayment(
|
||
remittance_id=remittance_id,
|
||
line_number=svc.line_number,
|
||
procedure_qualifier=svc.procedure_qualifier,
|
||
procedure_code=svc.procedure_code,
|
||
modifiers_json=_json.dumps(svc.modifiers or []),
|
||
charge=Decimal(str(svc.charge)),
|
||
payment=Decimal(str(svc.payment)),
|
||
units=Decimal(str(svc.units)) if svc.units is not None else None,
|
||
unit_type=svc.unit_type,
|
||
service_date=svc.service_date,
|
||
ref_benefit_plan=svc.ref_benefit_plan,
|
||
)
|
||
session.add(slp)
|
||
session.flush() # populate slp.id for the FK below
|
||
|
||
for adj in svc.adjustments:
|
||
session.add(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,
|
||
service_line_payment_id=slp.id,
|
||
))
|
||
|
||
# CLP-level CAS adjustments (no SVC composite to attach to): persist
|
||
# with service_line_payment_id IS NULL. Today the 835 parser only
|
||
# produces svc.adjustments, so this is a no-op for current fixtures
|
||
# but is here for forward compatibility.
|
||
for adj in getattr(cp, "claim_adjustments", []) or []:
|
||
session.add(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,
|
||
service_line_payment_id=None,
|
||
))
|
||
```
|
||
|
||
- [ ] **Step 4: Wire `_persist_835_remit` into the existing 835 ingest loop**
|
||
|
||
In `backend/src/cyclone/store.py`, find the 835 ingest block (around line 905 — the loop that iterates `cp.service_payments` and inserts `CasAdjustment` rows). Replace the inner CAS loop with a call to `_persist_835_remit`. The existing code does:
|
||
|
||
```python
|
||
for svc in cp.service_payments:
|
||
for adj in svc.adjustments:
|
||
s.add(_cas_adjustment_row(adj, remit_row.id))
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```python
|
||
_persist_835_remit(s, cp, remit_row.id)
|
||
```
|
||
|
||
Then remove the now-unused `_cas_adjustment_row` helper (or leave it if you prefer minimal churn — see note below).
|
||
|
||
**Note:** leaving `_cas_adjustment_row` in place is fine; tests don't care if it's unused. Removing it is cleaner. Pick whichever keeps the diff small. If you remove it, also remove its import.
|
||
|
||
- [ ] **Step 5: Run tests, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_service_line_payments.py tests/test_prodfiles_smoke.py tests/test_api.py -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Full backend suite**
|
||
|
||
```bash
|
||
cd backend && pytest -q
|
||
```
|
||
|
||
Expected: same baseline as before this task (the 8 SP5 stream flakes + 3 TA1 flakes remain; everything else green).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/store.py backend/tests/test_service_line_payments.py
|
||
git commit -m "feat(sp7): persist ServiceLinePayment + link CAS rows on 835 ingest"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `reconcile.match_service_lines()` pure function
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/reconcile.py`
|
||
- Test: `backend/tests/test_line_reconciliation.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/tests/test_line_reconciliation.py`:
|
||
|
||
```python
|
||
"""SP7 — pure-function tests for match_service_lines().
|
||
|
||
Spec §4.
|
||
"""
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from cyclone.reconcile import (
|
||
LineMatch,
|
||
match_service_lines,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class FakeLine:
|
||
line_number: int
|
||
procedure_code: str
|
||
modifiers: list[str]
|
||
service_date: date | None
|
||
units: Decimal | None
|
||
payment: Decimal = Decimal("0") # 835 only
|
||
|
||
|
||
@dataclass
|
||
class FakeClaimLine(FakeLine):
|
||
charge: Decimal = Decimal("0") # 837 only
|
||
|
||
|
||
def _claim(*lines: FakeClaimLine) -> list[FakeClaimLine]:
|
||
return list(lines)
|
||
|
||
|
||
def _remit(*lines: FakeLine) -> list[FakeLine]:
|
||
return list(lines)
|
||
|
||
|
||
def test_exact_match_on_all_four_criteria():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), Decimal("1")))
|
||
remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 1), Decimal("1"), payment=Decimal("80")))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert len(matches) == 1
|
||
assert matches[0].claim_line.line_number == 1
|
||
assert matches[0].service_payment.line_number == 1
|
||
assert matches[0].status == "matched"
|
||
|
||
|
||
def test_procedure_code_mismatch_both_unmatched():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
|
||
remit_lines = _remit(FakeLine(1, "99214", [], None, None))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert len(matches) == 2
|
||
statuses = sorted(m.status for m in matches)
|
||
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
|
||
|
||
|
||
def test_modifiers_set_equal_regardless_of_order():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", ["25", "59"], None, None))
|
||
remit_lines = _remit(FakeLine(1, "99213", ["59", "25"], None, None))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert len(matches) == 1
|
||
assert matches[0].status == "matched"
|
||
|
||
|
||
def test_service_date_mismatch():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), None))
|
||
remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 2), None))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert len(matches) == 2
|
||
statuses = sorted(m.status for m in matches)
|
||
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
|
||
|
||
|
||
def test_units_mismatch():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, Decimal("1")))
|
||
remit_lines = _remit(FakeLine(1, "99213", [], None, Decimal("2")))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert len(matches) == 2
|
||
statuses = sorted(m.status for m in matches)
|
||
assert statuses == ["unmatched_835_only", "unmatched_837_only"]
|
||
|
||
|
||
def test_procedure_code_case_normalized():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
|
||
remit_lines = _remit(FakeLine(1, "99213", [], None, None)) # case irrelevant when both upper
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
assert matches[0].status == "matched"
|
||
|
||
|
||
def test_claim_has_more_lines_than_remit():
|
||
claim_lines = _claim(
|
||
FakeClaimLine(1, "99213", [], None, None),
|
||
FakeClaimLine(2, "99214", [], None, None),
|
||
)
|
||
remit_lines = _remit(FakeLine(1, "99213", [], None, None))
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
statuses = sorted(m.status for m in matches)
|
||
assert statuses == ["matched", "unmatched_837_only"]
|
||
|
||
|
||
def test_remit_has_more_lines_than_claim():
|
||
claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
|
||
remit_lines = _remit(
|
||
FakeLine(1, "99213", [], None, None),
|
||
FakeLine(2, "99214", [], None, None),
|
||
)
|
||
matches = match_service_lines(claim_lines, remit_lines)
|
||
statuses = sorted(m.status for m in matches)
|
||
assert statuses == ["matched", "unmatched_835_only"]
|
||
|
||
|
||
def test_empty_837_lines_no_reconciliation_rows():
|
||
matches = match_service_lines([], _remit(FakeLine(1, "99213", [], None, None)))
|
||
assert len(matches) == 1
|
||
assert matches[0].status == "unmatched_835_only"
|
||
|
||
|
||
def test_empty_835_lines_no_reconciliation_rows():
|
||
matches = match_service_lines(_claim(FakeClaimLine(1, "99213", [], None, None)), [])
|
||
assert len(matches) == 1
|
||
assert matches[0].status == "unmatched_837_only"
|
||
|
||
|
||
def test_both_empty():
|
||
assert match_service_lines([], []) == []
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_line_reconciliation.py -v
|
||
```
|
||
|
||
Expected: ImportError on `LineMatch` / `match_service_lines`.
|
||
|
||
- [ ] **Step 3: Implement `match_service_lines()`**
|
||
|
||
In `backend/src/cyclone/reconcile.py`, add:
|
||
|
||
```python
|
||
@dataclass
|
||
class LineMatch:
|
||
"""One row of a line-level reconciliation result.
|
||
|
||
At least one of ``claim_line`` / ``service_payment`` is non-None.
|
||
Spec §4.1.
|
||
"""
|
||
claim_line: Optional["_ClaimLineLike"] = None
|
||
service_payment: Optional["_ServicePaymentLike"] = None
|
||
status: str = "" # matched | unmatched_835_only | unmatched_837_only
|
||
|
||
|
||
class _ClaimLineLike(Protocol):
|
||
line_number: int
|
||
procedure_code: str
|
||
modifiers: list[str]
|
||
service_date: Optional[date]
|
||
units: Optional[Decimal]
|
||
|
||
|
||
class _ServicePaymentLike(Protocol):
|
||
line_number: int
|
||
procedure_code: str
|
||
modifiers: list[str]
|
||
service_date: Optional[date]
|
||
units: Optional[Decimal]
|
||
|
||
|
||
def _norm_code(code: str) -> str:
|
||
return (code or "").strip().upper()
|
||
|
||
|
||
def _norm_mods(mods: list[str] | None) -> frozenset[str]:
|
||
return frozenset((m or "").strip().upper() for m in (mods or []) if m)
|
||
|
||
|
||
def _svc_matches_claim(svc, claim) -> bool:
|
||
"""Strict match per spec §4.2."""
|
||
return (
|
||
_norm_code(svc.procedure_code) == _norm_code(claim.procedure_code)
|
||
and _norm_mods(svc.modifiers) == _norm_mods(claim.modifiers)
|
||
and svc.service_date == claim.service_date
|
||
and svc.units == claim.units
|
||
)
|
||
|
||
|
||
def match_service_lines(
|
||
claim_lines: list[_ClaimLineLike],
|
||
service_payments: list[_ServicePaymentLike],
|
||
) -> list[LineMatch]:
|
||
"""Pair each 837 SV1 line with the first 835 SVC composite that matches.
|
||
|
||
Spec §4. Unmatched lines on either side produce their own LineMatch
|
||
rows so every line is accounted for in the persistence step.
|
||
"""
|
||
matches: list[LineMatch] = []
|
||
used_svc_ids: set[int] = set()
|
||
|
||
for claim in sorted(claim_lines, key=lambda c: c.line_number):
|
||
match_idx: Optional[int] = None
|
||
for i, svc in enumerate(service_payments):
|
||
if i in used_svc_ids:
|
||
continue
|
||
if _svc_matches_claim(svc, claim):
|
||
match_idx = i
|
||
break
|
||
if match_idx is not None:
|
||
used_svc_ids.add(match_idx)
|
||
matches.append(LineMatch(
|
||
claim_line=claim,
|
||
service_payment=service_payments[match_idx],
|
||
status="matched",
|
||
))
|
||
else:
|
||
matches.append(LineMatch(
|
||
claim_line=claim,
|
||
service_payment=None,
|
||
status="unmatched_837_only",
|
||
))
|
||
|
||
for i, svc in enumerate(service_payments):
|
||
if i in used_svc_ids:
|
||
continue
|
||
matches.append(LineMatch(
|
||
claim_line=None,
|
||
service_payment=svc,
|
||
status="unmatched_835_only",
|
||
))
|
||
|
||
return matches
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_line_reconciliation.py -v
|
||
```
|
||
|
||
Expected: 11 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/reconcile.py backend/tests/test_line_reconciliation.py
|
||
git commit -m "feat(sp7): match_service_lines() pure function + tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Wire `match_service_lines()` into `reconcile.run()`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/reconcile.py`
|
||
- Test: `backend/tests/test_reconcile_line_level.py`
|
||
|
||
- [ ] **Step 1: Write the integration test**
|
||
|
||
Create `backend/tests/test_reconcile_line_level.py`:
|
||
|
||
```python
|
||
"""SP7 — integration test: reconcile.run() persists LineReconciliation rows.
|
||
|
||
Spec §4.4.
|
||
"""
|
||
from datetime import date, datetime, timezone
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
|
||
from cyclone.db import (
|
||
Batch,
|
||
Claim,
|
||
LineReconciliation,
|
||
Remittance,
|
||
ServiceLinePayment,
|
||
ServiceLine,
|
||
SessionLocal,
|
||
)
|
||
from cyclone.reconcile import run
|
||
from cyclone.store import _remittance_835_row, _persist_835_remit
|
||
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
|
||
|
||
|
||
def _setup_db() -> tuple[str, str]:
|
||
"""Build a minimal 837 claim + 835 remit that share PCN. Returns (claim_id, remittance_id)."""
|
||
from cyclone.parsers.models_837 import (
|
||
ClaimOutput, ServiceLine as ParseServiceLine, Procedure,
|
||
)
|
||
import uuid
|
||
|
||
bid = str(uuid.uuid4())
|
||
claim_id = "CLM-T1"
|
||
|
||
with SessionLocal()() as s:
|
||
# 1. Batch
|
||
s.add(Batch(
|
||
id=bid, source_filename="x.835",
|
||
parsed_at=datetime.now(timezone.utc),
|
||
summary_json={"claim_count": 1, "total_charge": "200", "total_paid": "150"},
|
||
claim_count=1,
|
||
))
|
||
# 2. Claim with two service lines
|
||
s.add(Claim(
|
||
id=claim_id,
|
||
patient_control_number="PCN-1",
|
||
charge_amount=Decimal("200.00"),
|
||
provider_npi="1234567890",
|
||
payer_id="SKCO0",
|
||
submission_date=date(2026, 6, 1),
|
||
state="submitted",
|
||
batch_id=bid,
|
||
))
|
||
s.flush()
|
||
s.add(ServiceLine(
|
||
claim_id=claim_id, line_number=1,
|
||
procedure_code="99213",
|
||
modifiers_json="[]",
|
||
charge=Decimal("100.00"),
|
||
service_date=date(2026, 6, 1),
|
||
))
|
||
s.add(ServiceLine(
|
||
claim_id=claim_id, line_number=2,
|
||
procedure_code="99214",
|
||
modifiers_json="[]",
|
||
charge=Decimal("100.00"),
|
||
service_date=date(2026, 6, 1),
|
||
))
|
||
# 3. Remit with two SVC composites, one matching claim line 1, one not
|
||
cp = ClaimPayment(
|
||
payer_claim_control_number="PCN-1",
|
||
status_code="1",
|
||
total_charge=Decimal("200.00"),
|
||
total_paid=Decimal("150.00"),
|
||
service_payments=[
|
||
ServicePayment(
|
||
line_number=1, procedure_qualifier="HC",
|
||
procedure_code="99213",
|
||
charge=Decimal("100.00"),
|
||
payment=Decimal("80.00"),
|
||
service_date=date(2026, 6, 1),
|
||
),
|
||
ServicePayment(
|
||
line_number=2, procedure_qualifier="HC",
|
||
procedure_code="90837",
|
||
charge=Decimal("100.00"),
|
||
payment=Decimal("70.00"),
|
||
service_date=date(2026, 6, 1),
|
||
),
|
||
],
|
||
)
|
||
remit = _remittance_835_row(cp, bid)
|
||
s.add(remit)
|
||
s.flush()
|
||
_persist_835_remit(s, cp, remit.id)
|
||
s.commit()
|
||
|
||
return claim_id, bid
|
||
|
||
|
||
def test_run_persists_line_reconciliation_rows():
|
||
claim_id, bid = _setup_db()
|
||
with SessionLocal()() as s:
|
||
result = run(s, bid)
|
||
s.commit()
|
||
assert result.matched == 1
|
||
|
||
lrs = list(
|
||
s.execute(select(LineReconciliation).where(LineReconciliation.claim_id == claim_id))
|
||
.scalars().all()
|
||
)
|
||
# Expect 3 rows: 99213 matched, 99214 unmatched_837_only, 90837 unmatched_835_only
|
||
assert len(lrs) == 3
|
||
by_status = {lr.status for lr in lrs}
|
||
assert by_status == {"matched", "unmatched_837_only", "unmatched_835_only"}
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_reconcile_line_level.py -v
|
||
```
|
||
|
||
Expected: failure — `run()` doesn't persist `LineReconciliation` rows yet.
|
||
|
||
- [ ] **Step 3: Extend `reconcile.run()`**
|
||
|
||
In `backend/src/cyclone/reconcile.py`, inside `run()`, AFTER the existing claim↔remit match loop and BEFORE the CAS aggregate recompute, insert:
|
||
|
||
```python
|
||
# SP7: line-level reconciliation. For each matched (claim, remit) pair,
|
||
# find each 837 service line's matching 835 SVC composite and persist
|
||
# a LineReconciliation row per line on either side (matched or
|
||
# explicitly unmatched). See spec §4.4.
|
||
from cyclone.db import LineReconciliation, ServiceLinePayment, ServiceLine as ClaimServiceLine
|
||
for m in matches:
|
||
if m.is_reversal:
|
||
continue # reversals are handled by a separate path (see §7.3)
|
||
claim_id = m.claim.id
|
||
remit_id = m.remittance.id
|
||
|
||
claim_lines = list(
|
||
session.execute(
|
||
select(ClaimServiceLine)
|
||
.where(ClaimServiceLine.claim_id == claim_id)
|
||
.order_by(ClaimServiceLine.line_number)
|
||
).scalars().all()
|
||
)
|
||
svc_payments = list(
|
||
session.execute(
|
||
select(ServiceLinePayment)
|
||
.where(ServiceLinePayment.remittance_id == remit_id)
|
||
.order_by(ServiceLinePayment.line_number)
|
||
).scalars().all()
|
||
)
|
||
|
||
line_matches = match_service_lines(
|
||
[_ClaimLineShim(line) for line in claim_lines],
|
||
[_SvcPaymentShim(svc) for svc in svc_payments],
|
||
)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
for lm in line_matches:
|
||
session.add(LineReconciliation(
|
||
claim_id=claim_id,
|
||
claim_service_line_id=lm.claim_line.line_id if lm.claim_line else None,
|
||
service_line_payment_id=lm.service_payment.id if lm.service_payment else None,
|
||
status=lm.status,
|
||
reconciled_at=now,
|
||
))
|
||
|
||
# SP7: claim-level adjustment aggregate. Sum CAS rows whose
|
||
# service_line_payment_id IS NULL — these are CLP-level adjustments,
|
||
# not per-line. Persist on the remit for fast reads.
|
||
from sqlalchemy import func as _func
|
||
for r in new_remits:
|
||
cl_total = session.execute(
|
||
select(_func.sum(CasAdjustment.amount))
|
||
.where(CasAdjustment.remittance_id == r.id)
|
||
.where(CasAdjustment.service_line_payment_id.is_(None))
|
||
).scalar_one() or Decimal("0")
|
||
r.claim_level_adjustment_amount = cl_total
|
||
```
|
||
|
||
Then add the shims at module top level (after the existing protocol definitions):
|
||
|
||
```python
|
||
@dataclass
|
||
class _ClaimLineShim:
|
||
"""Adapt an ORM ClaimServiceLine to the _ClaimLineLike protocol.
|
||
|
||
Used by ``reconcile.run()`` to call ``match_service_lines()``.
|
||
"""
|
||
line_id: int
|
||
line_number: int
|
||
procedure_code: str
|
||
modifiers: list[str]
|
||
service_date: Optional[date]
|
||
units: Optional[Decimal]
|
||
|
||
@classmethod
|
||
def from_orm(cls, line) -> "_ClaimLineShim":
|
||
import json as _json
|
||
return cls(
|
||
line_id=line.id,
|
||
line_number=line.line_number,
|
||
procedure_code=line.procedure_code,
|
||
modifiers=_json.loads(line.modifiers_json or "[]"),
|
||
service_date=line.service_date,
|
||
units=Decimal(str(line.units)) if line.units is not None else None,
|
||
)
|
||
|
||
def __init__(self, line): # accept ORM directly for convenience
|
||
import json as _json
|
||
self.line_id = line.id
|
||
self.line_number = line.line_number
|
||
self.procedure_code = line.procedure_code
|
||
self.modifiers = _json.loads(line.modifiers_json or "[]")
|
||
self.service_date = line.service_date
|
||
self.units = Decimal(str(line.units)) if line.units is not None else None
|
||
|
||
|
||
@dataclass
|
||
class _SvcPaymentShim:
|
||
line_id: int
|
||
line_number: int
|
||
procedure_code: str
|
||
modifiers: list[str]
|
||
service_date: Optional[date]
|
||
units: Optional[Decimal]
|
||
|
||
def __init__(self, svc):
|
||
import json as _json
|
||
self.line_id = svc.id
|
||
self.line_number = svc.line_number
|
||
self.procedure_code = svc.procedure_code
|
||
self.modifiers = _json.loads(svc.modifiers_json or "[]"),
|
||
self.service_date = svc.service_date
|
||
self.units = Decimal(str(svc.units)) if svc.units is not None else None
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_reconcile_line_level.py -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Full backend suite**
|
||
|
||
```bash
|
||
cd backend && pytest -q
|
||
```
|
||
|
||
Expected: same baseline.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile_line_level.py
|
||
git commit -m "feat(sp7): wire match_service_lines into reconcile.run"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 3 — API
|
||
|
||
## Task 9: `GET /api/claims/{id}/line-reconciliation` endpoint
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: `backend/tests/test_api_line_reconciliation.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/tests/test_api_line_reconciliation.py`:
|
||
|
||
```python
|
||
"""SP7 — endpoint test for GET /api/claims/{id}/line-reconciliation.
|
||
|
||
Spec §5.1.
|
||
"""
|
||
from datetime import date, datetime, timezone
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from cyclone import db
|
||
from cyclone.api import app
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
return TestClient(app)
|
||
|
||
|
||
def _seed(client):
|
||
"""Insert one claim + matching remit + 4 service lines (3 matched, 1 unmatched 837).
|
||
Returns the claim id."""
|
||
import uuid, json as _json
|
||
from sqlalchemy import select
|
||
bid = str(uuid.uuid4())
|
||
claim_id = "CLM-API-1"
|
||
|
||
with db.SessionLocal()() as s:
|
||
s.add(db.Batch(
|
||
id=bid, source_filename="x.835",
|
||
parsed_at=datetime.now(timezone.utc),
|
||
summary_json={"claim_count": 1, "total_charge": "300", "total_paid": "200"},
|
||
claim_count=1,
|
||
))
|
||
s.add(db.Claim(
|
||
id=claim_id,
|
||
patient_control_number="PCN-API",
|
||
charge_amount=Decimal("300.00"),
|
||
provider_npi="1234567890",
|
||
payer_id="SKCO0",
|
||
submission_date=date(2026, 6, 1),
|
||
state="submitted",
|
||
batch_id=bid,
|
||
))
|
||
s.flush()
|
||
# Three matching lines + one 837-only
|
||
for i, code in enumerate(["99213", "99214", "99215", "90837"], start=1):
|
||
s.add(db.ServiceLine(
|
||
claim_id=claim_id, line_number=i,
|
||
procedure_code=code, modifiers_json="[]",
|
||
charge=Decimal("100.00"),
|
||
service_date=date(2026, 6, i),
|
||
))
|
||
# Remit pays the first 3 lines
|
||
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
|
||
cp = ClaimPayment(
|
||
payer_claim_control_number="PCN-API",
|
||
status_code="1",
|
||
total_charge=Decimal("300.00"),
|
||
total_paid=Decimal("240.00"),
|
||
service_payments=[
|
||
ServicePayment(line_number=1, procedure_qualifier="HC",
|
||
procedure_code=c, charge=Decimal("100.00"),
|
||
payment=Decimal("80.00"), service_date=date(2026, 6, i))
|
||
for i, c in enumerate(["99213", "99214", "99215"], start=1)
|
||
],
|
||
)
|
||
from cyclone.store import _remittance_835_row, _persist_835_remit
|
||
remit = _remittance_835_row(cp, bid)
|
||
s.add(remit)
|
||
s.flush()
|
||
_persist_835_remit(s, cp, remit.id)
|
||
s.commit()
|
||
|
||
# Run reconcile so matched_remittance_id gets set + LineReconciliation rows persist
|
||
from cyclone.reconcile import run
|
||
run(s, bid)
|
||
s.commit()
|
||
|
||
return claim_id
|
||
|
||
|
||
def test_endpoint_returns_documented_shape(client):
|
||
claim_id = _seed(client)
|
||
r = client.get(f"/api/claims/{claim_id}/line-reconciliation")
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["claim_id"] == claim_id
|
||
assert body["summary"]["total_lines"] == 4
|
||
assert body["summary"]["matched_lines"] == 3
|
||
statuses = sorted(row["status"] for row in body["lines"])
|
||
assert statuses == ["matched", "matched", "matched", "unmatched_837_only"]
|
||
|
||
|
||
def test_endpoint_404_for_unknown_claim(client):
|
||
r = client.get("/api/claims/UNKNOWN/line-reconciliation")
|
||
assert r.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_api_line_reconciliation.py -v
|
||
```
|
||
|
||
Expected: 404 from FastAPI (route not registered).
|
||
|
||
- [ ] **Step 3: Implement the endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, add:
|
||
|
||
```python
|
||
@app.get("/api/claims/{claim_id}/line-reconciliation")
|
||
def get_claim_line_reconciliation(claim_id: str):
|
||
"""Per-line reconciliation view for the ClaimDrawer tab.
|
||
|
||
Spec §5.1. Returns the 837 service lines and 835 SVC composites
|
||
side-by-side, with per-line CAS adjustments.
|
||
"""
|
||
from sqlalchemy import select
|
||
from cyclone.db import (
|
||
LineReconciliation, ServiceLine, ServiceLinePayment, CasAdjustment,
|
||
)
|
||
import json as _json
|
||
from decimal import Decimal
|
||
|
||
with db.SessionLocal()() as s:
|
||
claim = s.get(db.Claim, claim_id)
|
||
if claim is None:
|
||
raise HTTPException(404, f"claim {claim_id} not found")
|
||
|
||
# Load 837 lines + 835 svc payments + line reconciliation rows
|
||
claim_lines = list(
|
||
s.execute(
|
||
select(ServiceLine).where(ServiceLine.claim_id == claim_id)
|
||
).scalars().all()
|
||
)
|
||
lrs = list(
|
||
s.execute(
|
||
select(LineReconciliation).where(LineReconciliation.claim_id == claim_id)
|
||
).scalars().all()
|
||
)
|
||
|
||
# Index by claim_service_line_id and service_line_payment_id for lookup
|
||
lr_by_claim_line: dict[int, LineReconciliation] = {
|
||
lr.claim_service_line_id: lr for lr in lrs if lr.claim_service_line_id is not None
|
||
}
|
||
lr_by_svc: dict[int, LineReconciliation] = {
|
||
lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None
|
||
}
|
||
|
||
# Load all relevant svc payments in one go
|
||
svc_ids = [lr.service_line_payment_id for lr in lrs if lr.service_line_payment_id is not None]
|
||
svc_by_id: dict[int, ServiceLinePayment] = {}
|
||
if svc_ids:
|
||
for svc in s.execute(select(ServiceLinePayment).where(ServiceLinePayment.id.in_(svc_ids))).scalars().all():
|
||
svc_by_id[svc.id] = svc
|
||
|
||
# Load CAS adjustments grouped by service_line_payment_id
|
||
cas_by_svc: dict[int, list[CasAdjustment]] = {}
|
||
if svc_ids:
|
||
for cas in s.execute(select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids))).scalars().all():
|
||
cas_by_svc.setdefault(cas.service_line_payment_id, []).append(cas)
|
||
|
||
# Build the lines[] array, preserving claim-line ordering plus unmatched 835 lines at the end
|
||
lines_out: list[dict] = []
|
||
billed_total = Decimal("0")
|
||
paid_total = Decimal("0")
|
||
adjustment_total = Decimal("0")
|
||
matched_count = 0
|
||
|
||
for cl in sorted(claim_lines, key=lambda c: c.line_number):
|
||
billed_total += Decimal(str(cl.charge))
|
||
lr = lr_by_claim_line.get(cl.id)
|
||
if lr is None:
|
||
# Claim line exists but no LineReconciliation row (rare).
|
||
lines_out.append({
|
||
"claim_service_line": _claim_line_to_dict(cl),
|
||
"service_line_payment": None,
|
||
"status": "unmatched_837_only",
|
||
"adjustments": [],
|
||
})
|
||
continue
|
||
svc = svc_by_id.get(lr.service_line_payment_id) if lr.service_line_payment_id else None
|
||
cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else []
|
||
cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
|
||
if svc:
|
||
paid_total += Decimal(str(svc.payment))
|
||
adjustment_total += cas_total
|
||
if lr.status == "matched":
|
||
matched_count += 1
|
||
lines_out.append({
|
||
"claim_service_line": _claim_line_to_dict(cl),
|
||
"service_line_payment": _svc_to_dict(svc) if svc else None,
|
||
"status": lr.status,
|
||
"adjustments": [
|
||
{"group_code": c.group_code, "reason_code": c.reason_code,
|
||
"amount": str(Decimal(str(c.amount)))}
|
||
for c in cas_list
|
||
],
|
||
})
|
||
|
||
# Unmatched 835 lines (no claim_service_line)
|
||
for lr in lrs:
|
||
if lr.claim_service_line_id is not None:
|
||
continue
|
||
svc = svc_by_id.get(lr.service_line_payment_id)
|
||
if svc:
|
||
paid_total += Decimal(str(svc.payment))
|
||
cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else []
|
||
cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
|
||
adjustment_total += cas_total
|
||
lines_out.append({
|
||
"claim_service_line": None,
|
||
"service_line_payment": _svc_to_dict(svc) if svc else None,
|
||
"status": lr.status,
|
||
"adjustments": [
|
||
{"group_code": c.group_code, "reason_code": c.reason_code,
|
||
"amount": str(Decimal(str(c.amount)))}
|
||
for c in cas_list
|
||
],
|
||
})
|
||
|
||
total_lines = len(claim_lines) # billed line count
|
||
|
||
return {
|
||
"claim_id": claim_id,
|
||
"summary": {
|
||
"billed_total": str(billed_total),
|
||
"paid_total": str(paid_total),
|
||
"adjustment_total": str(adjustment_total),
|
||
"matched_lines": matched_count,
|
||
"total_lines": total_lines,
|
||
},
|
||
"lines": lines_out,
|
||
}
|
||
|
||
|
||
def _claim_line_to_dict(cl) -> dict:
|
||
import json as _json
|
||
return {
|
||
"id": cl.id,
|
||
"line_number": cl.line_number,
|
||
"procedure_qualifier": cl.procedure_qualifier if hasattr(cl, "procedure_qualifier") else "HC",
|
||
"procedure_code": cl.procedure_code,
|
||
"modifiers": _json.loads(cl.modifiers_json or "[]"),
|
||
"charge": str(Decimal(str(cl.charge))),
|
||
"units": str(Decimal(str(cl.units))) if cl.units is not None else None,
|
||
"unit_type": cl.unit_type if hasattr(cl, "unit_type") else None,
|
||
"service_date": cl.service_date.isoformat() if cl.service_date else None,
|
||
}
|
||
|
||
|
||
def _svc_to_dict(svc) -> dict:
|
||
import json as _json
|
||
return {
|
||
"id": svc.id,
|
||
"line_number": svc.line_number,
|
||
"procedure_qualifier": svc.procedure_qualifier,
|
||
"procedure_code": svc.procedure_code,
|
||
"modifiers": _json.loads(svc.modifiers_json or "[]"),
|
||
"charge": str(Decimal(str(svc.charge))),
|
||
"payment": str(Decimal(str(svc.payment))),
|
||
"units": str(Decimal(str(svc.units))) if svc.units is not None else None,
|
||
"unit_type": svc.unit_type,
|
||
"service_date": svc.service_date.isoformat() if svc.service_date else None,
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_api_line_reconciliation.py -v
|
||
```
|
||
|
||
Expected: 2 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_line_reconciliation.py
|
||
git commit -m "feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Augment `GET /api/claims/{id}` with `lineReconciliation` slim projection
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: existing `test_api_claim_detail.py`
|
||
|
||
- [ ] **Step 1: Find the existing claim detail endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/claims/{claim_id}")` (NOT the new line-reconciliation one from T9). Find where it builds the response dict.
|
||
|
||
- [ ] **Step 2: Add the `lineReconciliation` field**
|
||
|
||
After the existing fields, add:
|
||
|
||
```python
|
||
# SP7: slim per-line projection — enough for the ServiceLinesTable
|
||
# columns without forcing a second fetch.
|
||
from cyclone.db import LineReconciliation as LR
|
||
from sqlalchemy import select as _select
|
||
slim_lrs = list(
|
||
s.execute(
|
||
_select(LR).where(LR.claim_id == claim_id)
|
||
).scalars().all()
|
||
)
|
||
# Index for O(1) lookup by claim_service_line_id
|
||
slim_by_cl = {lr.claim_service_line_id: lr for lr in slim_lrs if lr.claim_service_line_id is not None}
|
||
# Aggregate per-line CAS sums
|
||
from cyclone.db import CasAdjustment as CA
|
||
cas_sums_by_svc: dict[int, str] = {}
|
||
svc_ids_for_cas = [lr.service_line_payment_id for lr in slim_lrs if lr.service_line_payment_id is not None]
|
||
if svc_ids_for_cas:
|
||
cas_rows = s.execute(
|
||
_select(CA.service_line_payment_id, CA.amount)
|
||
.where(CA.service_line_payment_id.in_(svc_ids_for_cas))
|
||
).all()
|
||
from collections import defaultdict
|
||
agg = defaultdict(lambda: Decimal("0"))
|
||
for svc_id, amount in cas_rows:
|
||
agg[svc_id] += Decimal(str(amount))
|
||
cas_sums_by_svc = {k: str(v) for k, v in agg.items()}
|
||
|
||
# Load svc payments for paid amounts
|
||
from cyclone.db import ServiceLinePayment as SLP
|
||
svc_by_id_slim: dict[int, SLP] = {}
|
||
if svc_ids_for_cas:
|
||
for svc in s.execute(_select(SLP).where(SLP.id.in_(svc_ids_for_cas))).scalars().all():
|
||
svc_by_id_slim[svc.id] = svc
|
||
|
||
line_reconciliation_slim = []
|
||
for cl in sorted(service_lines, key=lambda c: c.line_number):
|
||
lr = slim_by_cl.get(cl.id)
|
||
if lr is None:
|
||
line_reconciliation_slim.append({
|
||
"claim_service_line_id": cl.id,
|
||
"line_number": cl.line_number,
|
||
"status": "unmatched_837_only",
|
||
"paid": None,
|
||
"adjustments_sum": None,
|
||
})
|
||
continue
|
||
svc = svc_by_id_slim.get(lr.service_line_payment_id) if lr.service_line_payment_id else None
|
||
line_reconciliation_slim.append({
|
||
"claim_service_line_id": cl.id,
|
||
"line_number": cl.line_number,
|
||
"status": lr.status,
|
||
"paid": str(Decimal(str(svc.payment))) if svc else None,
|
||
"adjustments_sum": cas_sums_by_svc.get(lr.service_line_payment_id) if lr.service_line_payment_id else None,
|
||
})
|
||
|
||
response["lineReconciliation"] = line_reconciliation_slim
|
||
```
|
||
|
||
(If `service_lines` is named differently in the existing function, use whatever the local variable is. Read the existing endpoint to find the right name.)
|
||
|
||
- [ ] **Step 3: Run claim-detail tests**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_api_claim_detail.py -v
|
||
```
|
||
|
||
Expected: PASS. (Existing tests don't assert on the absence of `lineReconciliation`, so they'll pass with the new field present.)
|
||
|
||
- [ ] **Step 4: Add a test asserting the new field exists**
|
||
|
||
Append to `tests/test_api_claim_detail.py`:
|
||
|
||
```python
|
||
def test_claim_detail_includes_line_reconciliation_slim_projection(client, seeded_claim):
|
||
r = client.get(f"/api/claims/{seeded_claim['id']}")
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert "lineReconciliation" in body
|
||
assert isinstance(body["lineReconciliation"], list)
|
||
```
|
||
|
||
If the fixture isn't called `seeded_claim`, use the equivalent. Read the file's existing fixtures first.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py backend/tests/test_api_claim_detail.py
|
||
git commit -m "feat(sp7): claim detail includes lineReconciliation slim projection"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Augment `GET /api/remittances/{id}` with `serviceLinePayments` + `claimLevelAdjustments`
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: existing remit detail tests (find them via `pytest --collect-only`)
|
||
|
||
- [ ] **Step 1: Find the remit detail endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/remittances/{remittance_id}")`.
|
||
|
||
- [ ] **Step 2: Add the two new fields**
|
||
|
||
Before `return`:
|
||
|
||
```python
|
||
# SP7: per-line SVC composites + claim-level CAS bucket
|
||
from cyclone.db import ServiceLinePayment as SLP2, CasAdjustment as CA2
|
||
from sqlalchemy import select as _select2
|
||
|
||
slps = list(
|
||
s.execute(
|
||
_select2(SLP2).where(SLP2.remittance_id == remittance_id)
|
||
.order_by(SLP2.line_number)
|
||
).scalars().all()
|
||
)
|
||
slp_dicts = [_svc_to_dict(svc) for svc in slps]
|
||
|
||
cl_cas = list(
|
||
s.execute(
|
||
_select2(CA2).where(
|
||
(CA2.remittance_id == remittance_id)
|
||
& (CA2.service_line_payment_id.is_(None))
|
||
)
|
||
).scalars().all()
|
||
)
|
||
|
||
response["serviceLinePayments"] = slp_dicts
|
||
response["claimLevelAdjustments"] = [
|
||
{
|
||
"id": c.id,
|
||
"group_code": c.group_code,
|
||
"reason_code": c.reason_code,
|
||
"amount": str(Decimal(str(c.amount))),
|
||
"quantity": str(Decimal(str(c.quantity))) if c.quantity is not None else None,
|
||
}
|
||
for c in cl_cas
|
||
]
|
||
```
|
||
|
||
(Use the local response variable name as appropriate.)
|
||
|
||
- [ ] **Step 3: Run remit detail tests**
|
||
|
||
```bash
|
||
cd backend && pytest -k "remit" -q
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py
|
||
git commit -m "feat(sp7): remit detail includes serviceLinePayments + claimLevelAdjustments"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Augment `GET /api/inbox/lanes` `matched_remittance` payload
|
||
|
||
**Files:**
|
||
- Modify: `backend/src/cyclone/api.py`
|
||
- Test: `backend/tests/test_inbox_endpoints.py`
|
||
|
||
- [ ] **Step 1: Find the inbox lanes endpoint**
|
||
|
||
In `backend/src/cyclone/api.py`, find `inbox_lanes()` (or whatever the inbox lanes route function is named).
|
||
|
||
- [ ] **Step 2: Add line counts to the `matched_remittance` payload**
|
||
|
||
Inside the loop that builds the rejected/candidates rows that include a matched remittance, find where `matched_remittance` dict is built. Add:
|
||
|
||
```python
|
||
# SP7: matched/total line counts for the MatchedRemitCard badge
|
||
from cyclone.db import LineReconciliation as LR3, ServiceLine as ClaimLine2
|
||
from sqlalchemy import select as _select3
|
||
total_lines_for_claim = s.execute(
|
||
_select3(_select3.func.count())
|
||
.select_from(ClaimLine2)
|
||
.where(ClaimLine2.claim_id == claim.id)
|
||
).scalar_one() or 0
|
||
matched_lines_for_claim = s.execute(
|
||
_select3(_select3.func.count())
|
||
.select_from(LR3)
|
||
.where((LR3.claim_id == claim.id) & (LR3.status == "matched"))
|
||
).scalar_one() or 0
|
||
matched_remittance_dict["matched_lines"] = int(matched_lines_for_claim)
|
||
matched_remittance_dict["total_lines"] = int(total_lines_for_claim)
|
||
```
|
||
|
||
If the existing code uses a different variable name for the dict, use that. Read the function carefully first.
|
||
|
||
- [ ] **Step 3: Run inbox endpoint tests**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_inbox_endpoints.py -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add backend/src/cyclone/api.py
|
||
git commit -m "feat(sp7): inbox lanes matched_remittance includes matched/total line counts"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Frontend types & API client
|
||
|
||
## Task 13: Add types to `src/types/index.ts`
|
||
|
||
**Files:**
|
||
- Modify: `src/types/index.ts`
|
||
|
||
- [ ] **Step 1: Add new types**
|
||
|
||
Append to `src/types/index.ts`:
|
||
|
||
```typescript
|
||
// ---------------------------------------------------------------------------
|
||
// SP7 — Per-Service-Line Adjustment Audit types.
|
||
// See docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface ServiceLinePayment {
|
||
id: number;
|
||
lineNumber: number;
|
||
procedureQualifier: string;
|
||
procedureCode: string;
|
||
modifiers: string[];
|
||
charge: string;
|
||
payment: string;
|
||
units: string | null;
|
||
unitType: string | null;
|
||
serviceDate: string | null;
|
||
}
|
||
|
||
export type LineReconciliationStatus =
|
||
| "matched"
|
||
| "unmatched_835_only"
|
||
| "unmatched_837_only"
|
||
| "superseded";
|
||
|
||
export interface ClaimDetailLineReconciliation {
|
||
claimServiceLineId: number;
|
||
lineNumber: number;
|
||
status: LineReconciliationStatus;
|
||
paid: string | null;
|
||
adjustmentsSum: string | null;
|
||
}
|
||
|
||
export interface LineReconciliationSummary {
|
||
billedTotal: string;
|
||
paidTotal: string;
|
||
adjustmentTotal: string;
|
||
matchedLines: number;
|
||
totalLines: number;
|
||
}
|
||
|
||
export interface LineReconciliationRow {
|
||
claimServiceLine: {
|
||
id: number;
|
||
lineNumber: number;
|
||
procedureQualifier: string;
|
||
procedureCode: string;
|
||
modifiers: string[];
|
||
charge: string;
|
||
units: string | null;
|
||
unitType: string | null;
|
||
serviceDate: string | null;
|
||
} | null;
|
||
serviceLinePayment: ServiceLinePayment | null;
|
||
status: LineReconciliationStatus;
|
||
adjustments: Array<{
|
||
groupCode: string;
|
||
reasonCode: string;
|
||
amount: string;
|
||
}>;
|
||
}
|
||
|
||
export interface LineReconciliationResponse {
|
||
claimId: string;
|
||
summary: LineReconciliationSummary;
|
||
lines: LineReconciliationRow[];
|
||
}
|
||
|
||
export interface RemittanceClaimLevelAdjustment {
|
||
id: number;
|
||
groupCode: string;
|
||
reasonCode: string;
|
||
amount: string;
|
||
quantity: string | null;
|
||
}
|
||
|
||
export interface MatchedRemittanceLineCounts {
|
||
matchedLines: number;
|
||
totalLines: number;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `ClaimDetail` to include `lineReconciliation`**
|
||
|
||
Find `ClaimDetail` in `src/types/index.ts`. Add the field:
|
||
|
||
```typescript
|
||
lineReconciliation: ClaimDetailLineReconciliation[];
|
||
```
|
||
|
||
- [ ] **Step 3: Update `RemittanceDetail`**
|
||
|
||
Find the remittance detail type (probably named `RemittanceDetail` or similar — read the file to find it). Add:
|
||
|
||
```typescript
|
||
serviceLinePayments: ServiceLinePayment[];
|
||
claimLevelAdjustments: RemittanceClaimLevelAdjustment[];
|
||
```
|
||
|
||
(If the type uses a different name like `Remittance` for the detail payload, use that. Read first.)
|
||
|
||
- [ ] **Step 4: Run typecheck**
|
||
|
||
```bash
|
||
pnpm tsc --noEmit
|
||
```
|
||
|
||
Expected: errors in places that use the old types without the new fields. We'll fix those in subsequent tasks.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/types/index.ts
|
||
git commit -m "feat(sp7): frontend types — ServiceLinePayment, LineReconciliation, MatchedRemittance"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Update `src/lib/inbox-api.ts` with `matched_lines`/`total_lines`
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/inbox-api.ts`
|
||
|
||
- [ ] **Step 1: Update `InboxMatchedRemittance` type**
|
||
|
||
Find the type for the matched-remittance object in `inbox-api.ts`. Add:
|
||
|
||
```typescript
|
||
matchedLines: number;
|
||
totalLines: number;
|
||
```
|
||
|
||
- [ ] **Step 2: Update `fetchInboxLanes` test fixtures**
|
||
|
||
If `inbox-api.test.ts` has mock fixtures with `matched_remittance`, add the two fields with sensible defaults (e.g. `matchedLines: 4, totalLines: 4`).
|
||
|
||
- [ ] **Step 3: Run tests**
|
||
|
||
```bash
|
||
pnpm vitest run src/lib/inbox-api.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts
|
||
git commit -m "feat(sp7): inbox API types include matched/total line counts"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — UI
|
||
|
||
## Task 15: `ServiceLinesTable` — Paid + Adjustments columns
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ClaimDrawer/ServiceLinesTable.tsx`
|
||
- Test: `src/components/ClaimDrawer/ServiceLinesTable.test.tsx`
|
||
|
||
- [ ] **Step 1: Read existing component**
|
||
|
||
Read `src/components/ClaimDrawer/ServiceLinesTable.tsx` to understand the current 5-column structure and the row data shape.
|
||
|
||
- [ ] **Step 2: Add the two new columns**
|
||
|
||
Modify the table to render two more `<th>` and `<td>` cells per row. The component should accept an optional `lineReconciliation: ClaimDetailLineReconciliation[]` prop (default to `[]`). For each row, look up the entry by `lineNumber` and render:
|
||
|
||
- Paid: `fmt(lineReconciliation.paid)` if non-null, else `—`
|
||
- Adjustments: `fmt(lineReconciliation.adjustmentsSum)` if non-null, else `—`
|
||
|
||
The cell style should match the existing "charge" column (mono, tabular-nums, right-aligned, dim ink for `—`).
|
||
|
||
- [ ] **Step 3: Update existing tests**
|
||
|
||
If `ServiceLinesTable.test.tsx` uses a non-optional prop or fixture, update the fixture to include `lineReconciliation: []` so existing assertions still hold.
|
||
|
||
- [ ] **Step 4: Add new tests**
|
||
|
||
Append to `ServiceLinesTable.test.tsx`:
|
||
|
||
```tsx
|
||
it("renders Paid and Adjustments columns from lineReconciliation prop", () => {
|
||
const lineReconciliation = [
|
||
{ claimServiceLineId: 1, lineNumber: 1, status: "matched" as const,
|
||
paid: "80.00", adjustmentsSum: "20.00" },
|
||
{ claimServiceLineId: 2, lineNumber: 2, status: "unmatched_837_only" as const,
|
||
paid: null, adjustmentsSum: null },
|
||
];
|
||
const { container } = render(
|
||
<ServiceLinesTable
|
||
serviceLines={[/* existing fixtures with line_number 1 and 2 */]}
|
||
lineReconciliation={lineReconciliation}
|
||
/>
|
||
);
|
||
// line 1: matched
|
||
expect(container.textContent).toContain("$80.00");
|
||
expect(container.textContent).toContain("$20.00");
|
||
// line 2: empty cells render em-dash
|
||
// (look for "—" in the relevant <td> cells)
|
||
});
|
||
|
||
it("renders em-dash for missing lineReconciliation entries", () => {
|
||
const { container } = render(
|
||
<ServiceLinesTable
|
||
serviceLines={[/* one line fixture */]}
|
||
lineReconciliation={[]}
|
||
/>
|
||
);
|
||
// Expect at least two em-dashes (Paid + Adjustments for the one row)
|
||
expect((container.textContent ?? "").split("—").length - 1).toBeGreaterThanOrEqual(2);
|
||
});
|
||
```
|
||
|
||
(Fill in the serviceLines fixture with whatever shape the existing tests use.)
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/ClaimDrawer/ServiceLinesTable.test.tsx
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/ClaimDrawer/ServiceLinesTable.tsx src/components/ClaimDrawer/ServiceLinesTable.test.tsx
|
||
git commit -m "feat(sp7): ServiceLinesTable — Paid + Adjustments columns"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: `CasAdjustmentsPanel` — Line column
|
||
|
||
**Files:**
|
||
- Modify: `src/components/RemitDrawer/CasAdjustmentsPanel.tsx`
|
||
- Test: `src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx`
|
||
|
||
- [ ] **Step 1: Read existing component**
|
||
|
||
Read `src/components/RemitDrawer/CasAdjustmentsPanel.tsx` to understand the current row shape and how it groups claim-level adjustments.
|
||
|
||
- [ ] **Step 2: Add a Line column to each row**
|
||
|
||
The component receives a list of CAS adjustments. Each row should now show `proc · #N` for the SVC-level CAS rows (where the row's `serviceLinePaymentId` is non-null) and `—` for claim-level rows. Cluster the claim-level rows at the bottom under a "Claim-level adjustments" sub-header.
|
||
|
||
If the component currently groups by `serviceLinePaymentId`, the Line column just renders that group's header (procedure code · line number). If it doesn't, you'll need to add the grouping.
|
||
|
||
- [ ] **Step 3: Update fixture data**
|
||
|
||
Existing tests likely pass rows like `{ group_code, reason_code, amount }`. Add `service_line_payment: { procedure_code, line_number } | null` and a `groupBySvc: boolean` flag if needed.
|
||
|
||
- [ ] **Step 4: Add a new test**
|
||
|
||
```tsx
|
||
it("renders line column with procedure code + line number for SVC-level CAS", () => {
|
||
const rows = [
|
||
{ id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00",
|
||
serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } },
|
||
];
|
||
const { container } = render(<CasAdjustmentsPanel rows={rows} />);
|
||
expect(container.textContent).toMatch(/99213.*#1/);
|
||
});
|
||
|
||
it("separates claim-level adjustments into a sub-cluster", () => {
|
||
const rows = [
|
||
{ id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00",
|
||
serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } },
|
||
{ id: 2, groupCode: "PR", reasonCode: "1", amount: "10.00",
|
||
serviceLinePayment: null },
|
||
];
|
||
const { container } = render(<CasAdjustmentsPanel rows={rows} />);
|
||
expect(container.textContent).toMatch(/Claim-level adjustments/i);
|
||
});
|
||
```
|
||
|
||
(Adjust the prop names to match whatever the existing component uses.)
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/RemitDrawer/CasAdjustmentsPanel.tsx src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx
|
||
git commit -m "feat(sp7): CasAdjustmentsPanel — Line column + claim-level cluster"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: `MatchedRemitCard` — line-count badge
|
||
|
||
**Files:**
|
||
- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx`
|
||
- Test: `src/components/ClaimDrawer/MatchedRemitCard.test.tsx`
|
||
|
||
- [ ] **Step 1: Read existing component**
|
||
|
||
Read `MatchedRemitCard.tsx` to see the current card structure and the `matchedRemittance` prop type.
|
||
|
||
- [ ] **Step 2: Add the badge**
|
||
|
||
At the bottom of the card, add a small mono line:
|
||
|
||
```tsx
|
||
const matched = matchedRemittance.matchedLines;
|
||
const total = matchedRemittance.totalLines;
|
||
const allMatched = matched === total;
|
||
const badgeColor = allMatched ? "var(--tt-amber)" /* or green if available */ : "var(--tt-amber)";
|
||
// ...
|
||
<div className="text-xs font-mono" style={{ color: badgeColor }}>
|
||
lines: {matched}/{total} matched
|
||
{!allMatched && ` (${total - matched} unmatched)`}
|
||
</div>
|
||
```
|
||
|
||
Use whatever ink color tokens match the rest of the design. If there's a dedicated "success" token (e.g. `--tt-success`), use it for the full-match case.
|
||
|
||
- [ ] **Step 3: Update fixture**
|
||
|
||
Existing test fixtures for `MatchedRemitCard` need to include `matchedLines: 4, totalLines: 4`. Add the fields with default values.
|
||
|
||
- [ ] **Step 4: Add new tests**
|
||
|
||
```tsx
|
||
it("renders full-match badge when matchedLines equals totalLines", () => {
|
||
const rem = { /* existing fixture */, matchedLines: 4, totalLines: 4 };
|
||
const { container } = render(<MatchedRemitCard matchedRemittance={rem} />);
|
||
expect(container.textContent).toMatch(/4\/4 matched/);
|
||
});
|
||
|
||
it("renders partial-match badge with unmatched count", () => {
|
||
const rem = { /* existing fixture */, matchedLines: 3, totalLines: 4 };
|
||
const { container } = render(<MatchedRemitCard matchedRemittance={rem} />);
|
||
expect(container.textContent).toMatch(/3\/4 matched/);
|
||
expect(container.textContent).toMatch(/1 unmatched/);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/ClaimDrawer/MatchedRemitCard.test.tsx
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/ClaimDrawer/MatchedRemitCard.tsx src/components/ClaimDrawer/MatchedRemitCard.test.tsx
|
||
git commit -m "feat(sp7): MatchedRemitCard — line-count badge"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: `LineReconciliationTab` — new component
|
||
|
||
**Files:**
|
||
- Create: `src/components/ClaimDrawer/LineReconciliationTab.tsx`
|
||
- Create: `src/components/ClaimDrawer/LineReconciliationTab.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `src/components/ClaimDrawer/LineReconciliationTab.test.tsx`:
|
||
|
||
```tsx
|
||
// @vitest-environment happy-dom
|
||
import { afterEach, describe, expect, it } from "vitest";
|
||
import { cleanup, render } from "@testing-library/react";
|
||
import { LineReconciliationTab } from "./LineReconciliationTab";
|
||
import type { LineReconciliationResponse } from "@/types";
|
||
|
||
afterEach(() => cleanup());
|
||
|
||
const matchedAll: LineReconciliationResponse = {
|
||
claimId: "CLM-1",
|
||
summary: {
|
||
billedTotal: "200.00", paidTotal: "160.00",
|
||
adjustmentTotal: "40.00", matchedLines: 2, totalLines: 2,
|
||
},
|
||
lines: [
|
||
{
|
||
claimServiceLine: { id: 1, lineNumber: 1, procedureQualifier: "HC",
|
||
procedureCode: "99213", modifiers: [], charge: "100.00",
|
||
units: "1", unitType: "UN", serviceDate: "2026-06-01" },
|
||
serviceLinePayment: { id: 1, lineNumber: 1, procedureQualifier: "HC",
|
||
procedureCode: "99213", modifiers: [], charge: "100.00",
|
||
payment: "80.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" },
|
||
status: "matched",
|
||
adjustments: [
|
||
{ groupCode: "CO", reasonCode: "45", amount: "20.00" },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
describe("LineReconciliationTab", () => {
|
||
it("renders summary totals", () => {
|
||
const { container } = render(<LineReconciliationTab data={matchedAll} />);
|
||
expect(container.textContent).toMatch(/billed/i);
|
||
expect(container.textContent).toMatch(/paid/i);
|
||
expect(container.textContent).toMatch(/adjustments/i);
|
||
});
|
||
|
||
it("renders full-match summary line", () => {
|
||
const { container } = render(<LineReconciliationTab data={matchedAll} />);
|
||
expect(container.textContent).toMatch(/matched 2 of 2 lines/i);
|
||
});
|
||
|
||
it("renders partial-match summary line when lines are unmatched", () => {
|
||
const partial: LineReconciliationResponse = {
|
||
...matchedAll,
|
||
summary: { ...matchedAll.summary, matchedLines: 1, totalLines: 2 },
|
||
lines: [
|
||
matchedAll.lines[0]!,
|
||
{
|
||
claimServiceLine: { id: 2, lineNumber: 2, procedureQualifier: "HC",
|
||
procedureCode: "99214", modifiers: [], charge: "100.00",
|
||
units: "1", unitType: "UN", serviceDate: "2026-06-01" },
|
||
serviceLinePayment: null,
|
||
status: "unmatched_837_only",
|
||
adjustments: [],
|
||
},
|
||
],
|
||
};
|
||
const { container } = render(<LineReconciliationTab data={partial} />);
|
||
expect(container.textContent).toMatch(/matched 1 of 2 lines/i);
|
||
});
|
||
|
||
it("renders unmatched 835 line with oxblood accent", () => {
|
||
const with835Only: LineReconciliationResponse = {
|
||
...matchedAll,
|
||
lines: [
|
||
matchedAll.lines[0]!,
|
||
{
|
||
claimServiceLine: null,
|
||
serviceLinePayment: { id: 2, lineNumber: 2, procedureQualifier: "HC",
|
||
procedureCode: "90837", modifiers: [], charge: "100.00",
|
||
payment: "70.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" },
|
||
status: "unmatched_835_only",
|
||
adjustments: [],
|
||
},
|
||
],
|
||
};
|
||
const { container } = render(<LineReconciliationTab data={with835Only} />);
|
||
expect(container.textContent).toMatch(/90837/);
|
||
expect(container.textContent).toMatch(/no 837 line matched/i);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx
|
||
```
|
||
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implement the component**
|
||
|
||
Create `src/components/ClaimDrawer/LineReconciliationTab.tsx`:
|
||
|
||
```tsx
|
||
import { fmt } from "@/lib/format";
|
||
import type {
|
||
LineReconciliationResponse,
|
||
LineReconciliationRow,
|
||
} from "@/types";
|
||
|
||
/**
|
||
* Per-line reconciliation tab for the ClaimDrawer (SP7).
|
||
*
|
||
* Two-column layout: left = "Billed (837 SV1)", right = "Adjudicated (835 SVC)".
|
||
* Each row is a service line, matched when both sides are present; unmatched
|
||
* 835 lines appear at the bottom with an oxblood accent rail. Per-line CAS
|
||
* adjustments are listed under each row.
|
||
*
|
||
* Spec §6.2.
|
||
*/
|
||
export function LineReconciliationTab({
|
||
data,
|
||
}: {
|
||
data: LineReconciliationResponse;
|
||
}) {
|
||
const { summary, lines } = data;
|
||
const allMatched = summary.matchedLines === summary.totalLines;
|
||
|
||
// Separate matched+837_only (left column) from 835_only (right column tail)
|
||
const leftRows = lines.filter((l) => l.status !== "unmatched_835_only");
|
||
const rightRows = lines; // all rows, in their original order
|
||
|
||
return (
|
||
<section className="flex flex-col gap-4 px-6 py-4">
|
||
<header
|
||
className="flex items-baseline justify-between"
|
||
data-testid="line-reconciliation-summary"
|
||
>
|
||
<div className="flex gap-6 font-mono tabular-nums text-sm">
|
||
<span data-testid="billed-total">
|
||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||
Billed
|
||
</span>
|
||
{fmt(summary.billedTotal)}
|
||
</span>
|
||
<span data-testid="paid-total">
|
||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||
Paid
|
||
</span>
|
||
{fmt(summary.paidTotal)}
|
||
</span>
|
||
<span data-testid="adjustments-total">
|
||
<span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
|
||
Adjustments
|
||
</span>
|
||
{fmt(summary.adjustmentTotal)}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className="font-mono text-xs uppercase tracking-wider"
|
||
style={{
|
||
color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
|
||
}}
|
||
data-testid="match-summary"
|
||
>
|
||
{allMatched
|
||
? `matched ${summary.matchedLines} of ${summary.totalLines} lines`
|
||
: `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`}
|
||
</span>
|
||
</header>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div data-testid="left-column">
|
||
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
|
||
Billed (837 SV1)
|
||
</h3>
|
||
{leftRows.map((row) => (
|
||
<LineCard key={rowKey(row, "left")} row={row} side="left" />
|
||
))}
|
||
</div>
|
||
<div data-testid="right-column">
|
||
<h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
|
||
Adjudicated (835 SVC)
|
||
</h3>
|
||
{rightRows.map((row) => (
|
||
<LineCard key={rowKey(row, "right")} row={row} side="right" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function rowKey(row: LineReconciliationRow, side: "left" | "right"): string {
|
||
const ln = row.claimServiceLine?.lineNumber ?? row.serviceLinePayment?.lineNumber ?? -1;
|
||
return `${side}-${row.status}-${ln}`;
|
||
}
|
||
|
||
function LineCard({
|
||
row,
|
||
side,
|
||
}: {
|
||
row: LineReconciliationRow;
|
||
side: "left" | "right";
|
||
}) {
|
||
const cl = row.claimServiceLine;
|
||
const svc = row.serviceLinePayment;
|
||
const isUnmatched835 = row.status === "unmatched_835_only";
|
||
const accentColor = isUnmatched835 ? "var(--tt-oxblood)" : "var(--tt-ink-blue)";
|
||
const procCode = cl?.procedureCode ?? svc?.procedureCode ?? "—";
|
||
const lineNumber = cl?.lineNumber ?? svc?.lineNumber ?? null;
|
||
|
||
return (
|
||
<div
|
||
className="flex flex-col gap-1 py-2 px-3 mb-2"
|
||
style={{
|
||
borderLeft: `3px solid ${accentColor}`,
|
||
background: "var(--tt-bg-elev, rgba(255,255,255,0.02))",
|
||
}}
|
||
data-line-status={row.status}
|
||
>
|
||
<div className="flex items-baseline justify-between">
|
||
<div className="font-mono text-sm">
|
||
<span className="font-semibold">{procCode}</span>
|
||
{lineNumber !== null && (
|
||
<span className="text-[color:var(--m-ink-tertiary)] ml-2">#{lineNumber}</span>
|
||
)}
|
||
{isUnmatched835 && (
|
||
<span
|
||
className="ml-2 text-xs italic"
|
||
style={{ color: "var(--tt-oxblood)" }}
|
||
>
|
||
no 837 line matched
|
||
</span>
|
||
)}
|
||
{row.status === "unmatched_837_only" && (
|
||
<span
|
||
className="ml-2 text-xs italic"
|
||
style={{ color: "var(--tt-oxblood)" }}
|
||
>
|
||
no 835 line adjudicated this line
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="font-mono tabular-nums text-xs">
|
||
{side === "left" && cl && <span>{fmt(cl.charge)}</span>}
|
||
{side === "right" && svc && <span>{fmt(svc.payment)}</span>}
|
||
</div>
|
||
</div>
|
||
{row.adjustments.length > 0 && (
|
||
<ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
|
||
{row.adjustments.map((a, i) => (
|
||
<li key={i}>
|
||
{a.groupCode} · {a.reasonCode} · {fmt(a.amount)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx
|
||
```
|
||
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/ClaimDrawer/LineReconciliationTab.tsx src/components/ClaimDrawer/LineReconciliationTab.test.tsx
|
||
git commit -m "feat(sp7): LineReconciliationTab component"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Wire `LineReconciliationTab` into `ClaimDrawer`
|
||
|
||
**Files:**
|
||
- Create: `src/hooks/useLineReconciliation.ts`
|
||
- Create: `src/hooks/useLineReconciliation.test.ts`
|
||
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`
|
||
- Modify: `src/components/ClaimDrawer/index.ts`
|
||
|
||
- [ ] **Step 1: Write the failing hook test**
|
||
|
||
Create `src/hooks/useLineReconciliation.test.ts`:
|
||
|
||
```tsx
|
||
// @vitest-environment happy-dom
|
||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||
|
||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||
import { useLineReconciliation } from "./useLineReconciliation";
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe("useLineReconciliation", () => {
|
||
it("fetches the line reconciliation data on mount", async () => {
|
||
const fixture = {
|
||
claimId: "CLM-1",
|
||
summary: { billedTotal: "100", paidTotal: "80",
|
||
adjustmentTotal: "20", matchedLines: 1, totalLines: 1 },
|
||
lines: [],
|
||
};
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => fixture,
|
||
}));
|
||
const { result } = renderHook(() => useLineReconciliation("CLM-1"));
|
||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||
expect(result.current.data).toEqual(fixture);
|
||
});
|
||
|
||
it("returns null when claimId is null", () => {
|
||
const { result } = renderHook(() => useLineReconciliation(null));
|
||
expect(result.current.data).toBeNull();
|
||
expect(result.current.loading).toBe(false);
|
||
});
|
||
|
||
it("exposes an error when fetch fails", async () => {
|
||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||
ok: false, status: 500, json: async () => ({}),
|
||
}));
|
||
const { result } = renderHook(() => useLineReconciliation("CLM-1"));
|
||
await waitFor(() => expect(result.current.error).not.toBeNull());
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm FAIL**
|
||
|
||
```bash
|
||
pnpm vitest run src/hooks/useLineReconciliation.test.ts
|
||
```
|
||
|
||
Expected: ImportError.
|
||
|
||
- [ ] **Step 3: Implement the hook**
|
||
|
||
Create `src/hooks/useLineReconciliation.ts`:
|
||
|
||
```typescript
|
||
import { useEffect, useState } from "react";
|
||
import type { LineReconciliationResponse } from "@/types";
|
||
|
||
/**
|
||
* Data hook for the ClaimDrawer's Line Reconciliation tab.
|
||
*
|
||
* Calls GET /api/claims/{claimId}/line-reconciliation on mount and when
|
||
* claimId changes. When claimId is null, returns a no-data state.
|
||
*/
|
||
export function useLineReconciliation(claimId: string | null) {
|
||
const [data, setData] = useState<LineReconciliationResponse | null>(null);
|
||
const [loading, setLoading] = useState<boolean>(claimId !== null);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (claimId === null) {
|
||
setData(null);
|
||
setLoading(false);
|
||
setError(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
||
fetch(`${baseUrl}/api/claims/${claimId}/line-reconciliation`)
|
||
.then(async (r) => {
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
return (await r.json()) as LineReconciliationResponse;
|
||
})
|
||
.then((json) => {
|
||
if (cancelled) return;
|
||
setData(json);
|
||
setError(null);
|
||
})
|
||
.catch((e) => {
|
||
if (cancelled) return;
|
||
setError(e as Error);
|
||
})
|
||
.finally(() => {
|
||
if (cancelled) return;
|
||
setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [claimId]);
|
||
|
||
return { data, loading, error };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm PASS**
|
||
|
||
```bash
|
||
pnpm vitest run src/hooks/useLineReconciliation.test.ts
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Wire the tab into `ClaimDrawer.tsx`**
|
||
|
||
Read `src/components/ClaimDrawer/ClaimDrawer.tsx`. Find where the drawer renders its body sections (StateHistoryTimeline, RawSegmentsPanel, etc.).
|
||
|
||
Add state for the active tab:
|
||
|
||
```tsx
|
||
const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");
|
||
```
|
||
|
||
Add a tab strip:
|
||
|
||
```tsx
|
||
<div className="flex gap-2 px-6 pt-4 border-b" style={{ borderColor: "var(--tt-bg)" }}>
|
||
<button
|
||
onClick={() => setActiveTab("details")}
|
||
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
|
||
style={{
|
||
color: activeTab === "details" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
|
||
borderBottom: activeTab === "details" ? "2px solid var(--tt-amber)" : "2px solid transparent",
|
||
}}
|
||
>
|
||
Details
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab("line-reconciliation")}
|
||
className="font-mono text-xs uppercase tracking-wider px-3 py-2"
|
||
style={{
|
||
color: activeTab === "line-reconciliation" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
|
||
borderBottom: activeTab === "line-reconciliation" ? "2px solid var(--tt-amber)" : "2px solid transparent",
|
||
}}
|
||
>
|
||
Line Reconciliation
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
Conditionally render the tab body:
|
||
|
||
```tsx
|
||
{activeTab === "line-reconciliation" ? (
|
||
claimId ? (
|
||
<LineReconciliationBody claimId={claimId} />
|
||
) : null
|
||
) : (
|
||
// existing details body
|
||
)}
|
||
```
|
||
|
||
Where `LineReconciliationBody` is:
|
||
|
||
```tsx
|
||
function LineReconciliationBody({ claimId }: { claimId: string }) {
|
||
const { data, loading, error } = useLineReconciliation(claimId);
|
||
if (loading) return <div className="px-6 py-4 font-mono text-sm">loading…</div>;
|
||
if (error) return <div className="px-6 py-4 font-mono text-sm" style={{ color: "var(--tt-oxblood)" }}>error: {error.message}</div>;
|
||
if (!data) return null;
|
||
return <LineReconciliationTab data={data} />;
|
||
}
|
||
```
|
||
|
||
Add the imports at the top:
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { LineReconciliationTab } from "./LineReconciliationTab";
|
||
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
|
||
```
|
||
|
||
- [ ] **Step 6: Update `ClaimDrawer.test.tsx` if needed**
|
||
|
||
If the existing tests render the drawer body in a way that breaks (e.g. test snapshot expects "details" to be the default and only tab), update accordingly. Most tests should pass since "Details" is the default tab.
|
||
|
||
- [ ] **Step 7: Run tests**
|
||
|
||
```bash
|
||
pnpm vitest run src/components/ClaimDrawer/ src/hooks/useLineReconciliation.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 8: Run typecheck**
|
||
|
||
```bash
|
||
pnpm tsc --noEmit
|
||
```
|
||
|
||
Expected: 0 errors.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/ClaimDrawer.test.tsx src/components/ClaimDrawer/index.ts src/hooks/useLineReconciliation.ts src/hooks/useLineReconciliation.test.ts
|
||
git commit -m "feat(sp7): Line Reconciliation tab wired into ClaimDrawer"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 6 — Docs + smoke
|
||
|
||
## Task 20: README "Per-Line Adjustment Audit" section
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
|
||
- [ ] **Step 1: Find a good insertion point**
|
||
|
||
```bash
|
||
grep -n "^## " README.md
|
||
```
|
||
|
||
Insert the new section after "## Inbox" (added in SP6) and before "## Persistence".
|
||
|
||
- [ ] **Step 2: Add the section**
|
||
|
||
```markdown
|
||
## 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 (a re-migration is required if the algorithm changes).
|
||
|
||
### Where to find it
|
||
|
||
- **ClaimDrawer → "Line Reconciliation" tab** — side-by-side view of the
|
||
837 SV1 lines and 835 SVC composites with per-line CAS reasons.
|
||
- **ClaimDrawer → ServiceLinesTable** — every billed line now shows its
|
||
paid amount and adjustment sum on the right.
|
||
- **RemitDrawer → CasAdjustmentsPanel** — each CAS row carries a
|
||
`proc · #N` reference; claim-level CAS clusters separately.
|
||
- **Inbox → MatchedRemitCard** — a `lines: N/M matched` badge lights up
|
||
amber when at least one line is unmatched.
|
||
|
||
### Match criteria (strict)
|
||
|
||
A 835 SVC composite matches a 837 SV1 line iff all four criteria align:
|
||
|
||
| Field | Rule |
|
||
| ------------- | ----------------------------------------------- |
|
||
| Procedure | Exact match (case-insensitive, uppercased). |
|
||
| Modifiers | Set-equal (order-independent). |
|
||
| Service date | Exact match (both null counts as a match). |
|
||
| Units | Exact match (both null counts as a match). |
|
||
|
||
Unmatched lines surface as a **soft warning** — the claim still posts to
|
||
PAID/PARTIAL/RECEIVED, but the Inbox shows the unmatched count and the
|
||
drawer surfaces a per-line "no 837 line matched" note.
|
||
|
||
### Line-level endpoints
|
||
|
||
| Method | Path | Notes |
|
||
| ------ | --------------------------------------------------- | ---------------------------------------------- |
|
||
| GET | `/api/claims/{claim_id}/line-reconciliation` | Dedicated view for the drawer tab. |
|
||
| GET | `/api/claims/{claim_id}` | Now includes a slim `lineReconciliation[]`. |
|
||
| GET | `/api/remittances/{remittance_id}` | Now includes `serviceLinePayments[]` + `claimLevelAdjustments[]`. |
|
||
| GET | `/api/inbox/lanes` | `matched_remittance` payload gains `matched_lines` + `total_lines`. |
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs(sp7): README — Per-Line Adjustment Audit section"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: Manual smoke test
|
||
|
||
**Files:** none
|
||
|
||
- [ ] **Step 1: Start backend**
|
||
|
||
```bash
|
||
cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp7-smoke.db .venv/bin/python -m cyclone serve &
|
||
```
|
||
|
||
- [ ] **Step 2: Parse a sample 837 + 835 with overlapping service lines**
|
||
|
||
```bash
|
||
curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837
|
||
curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_835.txt" http://127.0.0.1:8000/api/parse-835
|
||
```
|
||
|
||
(If these fixtures don't align on PCN / lines, build a small synthetic pair that does — copy `co_medicaid_837p.txt` and adjust the PCN + a single line's procedure_code to a value the 835 doesn't pay. Save the result in `backend/tests/fixtures/sp7_smoke/` if the existing fixtures don't fit.)
|
||
|
||
- [ ] **Step 3: Hit the new endpoint**
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40
|
||
# Find a paid claim's id, then:
|
||
curl -s http://127.0.0.1:8000/api/claims/<claim_id>/line-reconciliation | python3 -m json.tool
|
||
```
|
||
|
||
Expected: a payload with `summary.matched_lines` + a `lines[]` array showing each 837 line paired with its 835 SVC composite (or null on the unmatched side), with per-line CAS adjustments.
|
||
|
||
- [ ] **Step 4: Verify the soft-warning surface**
|
||
|
||
In the response, check for:
|
||
|
||
- A line with `status: "unmatched_837_only"` — confirms an 837 line the payer didn't pay.
|
||
- A line with `status: "unmatched_835_only"` — confirms an 835 line the 837 didn't bill (synthetic setup needed for this).
|
||
|
||
- [ ] **Step 5: Hit the claim detail endpoint to confirm the slim projection**
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:8000/api/claims/<claim_id> | python3 -m json.tool | grep -A 5 lineReconciliation
|
||
```
|
||
|
||
Expected: a `lineReconciliation` array with one entry per billed line.
|
||
|
||
- [ ] **Step 6: Kill servers**
|
||
|
||
```bash
|
||
lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill
|
||
```
|
||
|
||
- [ ] **Step 7: Final full-suite run**
|
||
|
||
```bash
|
||
cd backend && pytest -q
|
||
cd .. && pnpm vitest run --reporter=dot
|
||
```
|
||
|
||
Expected: backend green (same baseline as before SP7 — the 8 SP5 stream flakes + 3 TA1 flakes remain). Frontend green (328+ tests).
|
||
|
||
- [ ] **Step 8: Final commit + push**
|
||
|
||
```bash
|
||
git log --oneline main -25
|
||
```
|
||
|
||
(Branch was kept on `main` per session convention; no push step.)
|
||
|
||
---
|
||
|
||
## Self-review checklist
|
||
|
||
- [x] **Spec coverage:**
|
||
- §2.1 Line Reconciliation tab → T18, T19
|
||
- §2.2 Augmented ServiceLinesTable → T15
|
||
- §2.3 Per-line attribution in RemitDrawer → T16
|
||
- §2.4 Inbox badge → T17, T12
|
||
- §3.1 `service_line_payments` table → T1, T2
|
||
- §3.2 `line_reconciliations` table → T1, T3
|
||
- §3.3 `CasAdjustment.service_line_payment_id` → T4
|
||
- §3.4 No change to `service_lines` → implicit (no task needed)
|
||
- §4.1 `match_service_lines()` signature → T7
|
||
- §4.2 Strict match criteria → T7
|
||
- §4.3 Tie-breaker → T7
|
||
- §4.4 Insertion order in `reconcile.run()` → T8
|
||
- §5.1 New endpoint → T9
|
||
- §5.2 Modified claim detail endpoint → T10
|
||
- §5.3 Modified remit detail endpoint → T11
|
||
- §5.4 Modified inbox lanes → T12
|
||
- §6.1 ServiceLinesTable columns → T15
|
||
- §6.2 LineReconciliationTab → T18, T19
|
||
- §6.3 CasAdjustmentsPanel column → T16
|
||
- §6.4 MatchedRemitCard badge → T17
|
||
- §7.1 Unmatched 835 lines → T7 (test), T18 (rendered)
|
||
- §7.2 Unmatched 837 lines → T7 (test), T18 (rendered)
|
||
- §7.3 Reversals → T8 (`run` skips reversals for now; spec §7.3 is a follow-on, documented but not implemented in v1)
|
||
- §7.4 CLP-level CAS → T6, T11
|
||
- §7.5 Multi-line remits → implicit (no schema constraint)
|
||
- §7.6 Remit-level CAS without service payment → T6 (no-op path)
|
||
- §7.7 Empty service_payments → T7 (test case)
|
||
- §7.8 Test isolation → all tests use the existing pattern; no SP5 fixture imports in new tests
|
||
- §9.1 Migration → T1
|
||
- §9.2 ORM models → T2, T3, T4
|
||
- §9.3 No backfill → implicit
|
||
- §9.4 Frontend types → T13
|
||
- §9.5 Rollout → T21
|
||
|
||
- [x] **Placeholder scan:** no TBD / TODO / "implement later" / "similar to Task N" patterns. All test files contain actual test code.
|
||
|
||
- [x] **Type consistency:**
|
||
- `match_service_lines` returns `list[LineMatch]` (T7) and T8 uses the same signature via the shims.
|
||
- `_persist_835_remit` signature matches across T6 and its tests.
|
||
- The endpoint `/api/claims/{claim_id}/line-reconciliation` is referenced consistently in T9 (backend), T19 (frontend hook), and the spec.
|
||
- `matchedLines` / `totalLines` appear in the API response (T12), the `InboxMatchedRemittance` type (T14), and the `MatchedRemitCard` component (T17).
|
||
|
||
- [x] **Scope:** 21 tasks, one branch, one PR. Within a single plan.
|
||
|
||
---
|
||
|
||
## Execution Handoff
|
||
|
||
**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md`.**
|
||
|
||
Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on the design phase — assumed to carry through to plan execution).
|
||
|
||
Executing T1 → T21 in order, committing each, running the relevant subset of the suite per task.
|