From add6e982a4a52110ff817b9012f051f9f5f79bac Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 17:46:47 -0600 Subject: [PATCH] Populate receivedAmount from matched Remittance.total_paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this change, every claim — matched or not — came back from `/api/claims` with `receivedAmount: 0.0`. `to_ui_claim_from_orm` hardcoded the value, so the Dashboard's 'Received' KPI and the claim detail drawer's paid amount always read $0 regardless of whether the claim had been paired with a paying remittance. The fix threads the real value through the read path: - `to_ui_claim_from_orm` accepts a new `received_total: float` kwarg (default 0.0 for callers that don't have a remittance in scope). - `iter_claims` bulk-loads `Remittance.total_paid` for every matched claim id in the result set (single SQL roundtrip via `IN` filter — no N+1) and stamps the sum onto each claim dict. - `manual_match` passes `float(remit.total_paid)` from the remit it's already holding. - `manual_unmatch` reads `paired_remit.total_paid` before clearing the FK so the response still reports what was paid pre-unpair. - `add` (write path) and `list_unmatched` (filters matched-remit is NULL) pass `0.0` explicitly for readability. The dev seed CLI now also generates matched Remittance rows for every PAID/PARTIAL claim — `matched_remittance_id` is set on the claim, and the remittance's `total_paid` is derived from the billed amount using the same ratios the frontend's old sample fixtures used (60–100% for PAID, 20–50% for PARTIAL). That gives the Dashboard a non-zero 'Received' KPI on a fresh dev DB. New tests: - tests/test_iter_claims_received.py — 4 cases covering matched, unmatched, orphan FK, and bulk-load paths. Catches regressions if anyone re-introduces the hardcoded 0.0 or breaks the bulk query. --- backend/src/cyclone/seed_cli.py | 89 +++++++++++--- backend/src/cyclone/store.py | 56 +++++++-- backend/tests/test_iter_claims_received.py | 134 +++++++++++++++++++++ 3 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 backend/tests/test_iter_claims_received.py diff --git a/backend/src/cyclone/seed_cli.py b/backend/src/cyclone/seed_cli.py index edabb70..7275a5d 100644 --- a/backend/src/cyclone/seed_cli.py +++ b/backend/src/cyclone/seed_cli.py @@ -53,10 +53,11 @@ from datetime import datetime, timedelta, timezone import click -from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, SessionLocal +from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance, SessionLocal SEED_BATCH_PREFIX = "SEED-" SEED_CLAIM_PREFIX = "CLM-S" +SEED_REMIT_PREFIX = "REM-S" SEED_DEFAULT_COUNT = 96 SEED_ACTIVITY_LIMIT = 28 @@ -145,9 +146,17 @@ def _pick(rng: random.Random, items): return items[rng.randint(0, len(items) - 1)] -def _build_seed_rows(count: int, *, seed: int = 42) -> tuple[Batch, list[Claim], list[ActivityEvent]]: +def _build_seed_rows( + count: int, *, seed: int = 42, +) -> tuple[Batch, list[Claim], list[ActivityEvent], list[Remittance]]: """Build a deterministic Batch + N Claims + matching activity events. + PAID and PARTIAL claims get a paired ``Remittance`` row (with a + realistic ``total_paid`` derived from the billed amount) so the + Dashboard's "Received" KPI lights up; the wire shape mirrors what + ``CycloneStore.iter_claims`` expects to find via + ``Claim.matched_remittance_id`` → ``Remittance.total_paid``. + The seed is fixed so re-running produces identical data — keeps screenshots and dev environments stable. """ @@ -167,6 +176,7 @@ def _build_seed_rows(count: int, *, seed: int = 42) -> tuple[Batch, list[Claim], claims: list[Claim] = [] activity: list[ActivityEvent] = [] + remittances: list[Remittance] = [] seq = 10428 # Match the frontend fixture's id range for i in range(count): @@ -193,6 +203,41 @@ def _build_seed_rows(count: int, *, seed: int = 42) -> tuple[Batch, list[Claim], claim_id = f"{SEED_CLAIM_PREFIX}{(seq + i):05d}" denial_reason = _pick(rng, DENIAL_REASONS) if state == ClaimState.DENIED else None + # Build a paired Remittance for any claim with received > 0. Status + # code 1 = "Primary payer forward" — the 835 CAS code that the + # remittance mapper turns into "received". Mirror the 835 parser's + # raw_json shape (a stripped provider/payer/service_lines block) + # so downstream debug views still render something useful. + matched_remit_id: str | None = None + if received > 0: + matched_remit_id = f"{SEED_REMIT_PREFIX}{(seq + i):05d}" + remittances.append(Remittance( + id=matched_remit_id, + batch_id=batch.id, + payer_claim_control_number=f"PCN-{(seq + i):05d}", + claim_id=claim_id, + status_code="1", + status_label="Primary payer forward", + total_charge=float(billed), + total_paid=float(received), + adjustment_amount=float(billed - received), + received_at=submitted + timedelta(days=rng.randint(2, 14)), + raw_json={ + "payer": {"name": payer}, + "provider": { + "npi": provider["npi"], + "name": provider["name"], + }, + "service_lines": [ + { + "procedure": {"code": cpt}, + "charge_amount": float(billed), + "paid_amount": float(received), + } + ], + }, + )) + raw_json = { "billing_provider": { "npi": provider["npi"], @@ -228,6 +273,7 @@ def _build_seed_rows(count: int, *, seed: int = 42) -> tuple[Batch, list[Claim], state_changed_at=submitted, rejection_reason=denial_reason, resubmit_count=0, + matched_remittance_id=matched_remit_id, raw_json=raw_json, )) @@ -263,7 +309,7 @@ def _build_seed_rows(count: int, *, seed: int = 42) -> tuple[Batch, list[Claim], payload_json=payload, )) - return batch, claims, activity + return batch, claims, activity, remittances def _existing_seed_batch_ids(s) -> list[str]: @@ -274,13 +320,12 @@ def _existing_seed_batch_ids(s) -> list[str]: def _delete_seed_rows(s) -> int: """Delete every row that was inserted by the seed. - The seed writes rows whose ids begin with ``SEED-`` (batches) or - ``CLM-S`` (claims). Activity events are joined to seeded batches - when the batch is still around, but we also clean up orphan - activity rows (no FK from ``activity_events.claim_id`` to - ``claims.id``) by ``claim_id`` prefix. Returns the number of - batch rows deleted (the rest of the counts are printed via - ``_print_status``). + The seed writes rows whose ids begin with ``SEED-`` (batches), + ``CLM-S`` (claims), or ``REM-S`` (remittances). Activity events + are joined to seeded batches when the batch is still around, but + we also clean up orphan activity rows (no FK from + ``activity_events.claim_id`` to ``claims.id``) by ``claim_id`` + prefix. Returns the number of batch rows deleted. """ # Activity first — its rows reference both claims and batches, so # delete the events tied to seed claims first, then any that were @@ -291,19 +336,28 @@ def _delete_seed_rows(s) -> int: activity2 = s.query(ActivityEvent).filter( ActivityEvent.batch_id.like(f"{SEED_BATCH_PREFIX}%") ).delete(synchronize_session=False) + # Remittances (FK to claims; cascade may not fire without FK pragma, + # so we delete them explicitly to clear the symmetric FK first). + remits = s.query(Remittance).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).delete(synchronize_session=False) claims = s.query(Claim).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).delete(synchronize_session=False) batches = s.query(Batch).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).delete(synchronize_session=False) s.commit() click.echo(f" Removed {batches} seeded batch(es), {claims} claim(s), " - f"{activity + activity2} activity event(s).") + f"{remits} remittance(s), {activity + activity2} activity event(s).") return batches -def _insert(batch: Batch, claims: list[Claim], activity: list[ActivityEvent]) -> None: +def _insert( + batch: Batch, + claims: list[Claim], + activity: list[ActivityEvent], + remittances: list[Remittance], +) -> None: with SessionLocal()() as s: s.add(batch) s.add_all(claims) s.add_all(activity) + s.add_all(remittances) s.commit() @@ -311,11 +365,15 @@ def _print_status(s) -> None: from sqlalchemy import func seed_batches = s.query(func.count(Batch.id)).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).scalar() or 0 seed_claims = s.query(func.count(Claim.id)).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).scalar() or 0 + seed_remits = s.query(func.count(Remittance.id)).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).scalar() or 0 total_claims = s.query(func.count(Claim.id)).scalar() or 0 + total_remits = s.query(func.count(Remittance.id)).scalar() or 0 total_activity = s.query(func.count(ActivityEvent.id)).scalar() or 0 click.echo(f" Seed batches: {seed_batches}") click.echo(f" Seed claims: {seed_claims}") + click.echo(f" Seed remits: {seed_remits}") click.echo(f" Total claims: {total_claims}") + click.echo(f" Total remits: {total_remits}") click.echo(f" Total activity: {total_activity}") @@ -359,15 +417,16 @@ def seed_cli(count: int, reset: bool, show_status: bool) -> None: click.echo(f" Removed {deleted} seeded batch(es).") try: - batch, claims, activity = _build_seed_rows(count) + batch, claims, activity, remittances = _build_seed_rows(count) except Exception as exc: click.echo(f"Failed to build seed rows: {exc}", err=True) sys.exit(1) try: - _insert(batch, claims, activity) + _insert(batch, claims, activity, remittances) except Exception as exc: click.echo(f"Failed to insert seed rows: {exc}", err=True) sys.exit(1) - click.echo(f" Inserted batch {batch.id} with {len(claims)} claims and {len(activity)} activity events.") + click.echo(f" Inserted batch {batch.id} with {len(claims)} claims, " + f"{len(remittances)} remittances, {len(activity)} activity events.") _print_status(s) \ No newline at end of file diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 0f2a942..81a654f 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -431,6 +431,7 @@ def to_ui_claim_from_orm( *, batch_id: str, parsed_at: datetime, + received_total: float = 0.0, ) -> dict: """Map an ORM ``Claim`` row to the UI's claim shape. @@ -476,7 +477,10 @@ def to_ui_claim_from_orm( "batchId": batch_id, # Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys # but expects these on freshly-loaded rows from /api/claims too. - "receivedAmount": 0.0, + # ``received_total`` comes from the matched Remittance row when one + # exists; callers that don't pre-compute it (write path, unmatched + # list) get the default of 0.0 — which matches the unmapped state. + "receivedAmount": float(received_total), "denialReason": None, } @@ -1068,6 +1072,9 @@ class CycloneStore: ui = to_ui_claim_from_orm( row, batch_id=row.batch_id or record.id, parsed_at=record.parsed_at, + # Fresh ingest — no remittance has been paired yet, + # so ``Received`` is necessarily 0. + received_total=0.0, ) self._sync_publish(event_bus, "claim_written", ui) for rid in remit_ids: @@ -1488,6 +1495,24 @@ class CycloneStore: q = q.filter(Claim.provider_npi == provider_npi) rows = q.all() + # Bulk-load matched-remittance totals so the UI's "Received" + # KPI + per-claim received_amount reflect real paid amounts + # rather than always-0. One SQL roundtrip for the whole page + # rather than per-claim lookups. + matched_ids = [ + r.matched_remittance_id + for r in rows + if r.matched_remittance_id + ] + received_by_remit: dict[str, float] = {} + if matched_ids: + for rid, total_paid in ( + s.query(Remittance.id, Remittance.total_paid) + .filter(Remittance.id.in_(matched_ids)) + .all() + ): + received_by_remit[rid] = float(total_paid or 0) + out: list[dict] = [] for r in rows: raw = r.raw_json or {} @@ -1516,7 +1541,9 @@ class CycloneStore: "payerName": payer_obj.get("name") or "", "cptCode": cpt, "billedAmount": float(r.charge_amount or 0), - "receivedAmount": 0.0, + "receivedAmount": received_by_remit.get( + r.matched_remittance_id, 0.0 + ), "status": r.state.value if hasattr(r.state, "value") else str(r.state), "state": r.state.value if hasattr(r.state, "value") else str(r.state), "denialReason": None, @@ -1936,6 +1963,9 @@ class CycloneStore: result["claims"].append( to_ui_claim_from_orm( r, batch_id=r.batch_id, parsed_at=parsed_at, + # list_unmatched filters matched_remittance_id IS NULL, + # so every row has no remittance yet. + received_total=0.0, ) ) @@ -2063,6 +2093,7 @@ class CycloneStore: ) claim_dict = to_ui_claim_from_orm( claim, batch_id=claim.batch_id, parsed_at=parsed_at, + received_total=float(remit.total_paid or 0), ) matched_at_iso = now.isoformat().replace("+00:00", "Z") return { @@ -2119,9 +2150,11 @@ class CycloneStore: # rows exist. Shouldn't happen, but if it does, fall back # to clearing the FK and starting fresh. latest = None + paired_remit = None restored_state = ClaimState.SUBMITTED else: latest = matches[0] + paired_remit = s.get(Remittance, latest.remittance_id) restored_state = ( latest.prior_claim_state if latest.prior_claim_state is not None @@ -2137,11 +2170,9 @@ class CycloneStore: # Clear the symmetric FK on the remittance so list_unmatched # surfaces the pair again. The remittance may have been # deleted between the match and this call — guard with a - # get() so we don't blow up on a stale FK. - if latest is not None: - paired_remit = s.get(Remittance, latest.remittance_id) - if paired_remit is not None: - paired_remit.claim_id = None + # None check so we don't blow up on a stale FK. + if paired_remit is not None: + paired_remit.claim_id = None now = utcnow() s.add(ActivityEvent( @@ -2161,8 +2192,19 @@ class CycloneStore: if claim.batch is not None else now ) + # ``paired_remit`` is the matched remittance we cleared in + # the unmatch; use its ``total_paid`` for the response shape + # so the UI sees what was paid before the unpair. May be + # ``None`` if the remittance was deleted since the match — + # default to 0.0 in that case. + received_total = ( + float(paired_remit.total_paid or 0) + if paired_remit is not None + else 0.0 + ) claim_dict = to_ui_claim_from_orm( claim, batch_id=claim.batch_id, parsed_at=parsed_at, + received_total=received_total, ) return { "claim": claim_dict, diff --git a/backend/tests/test_iter_claims_received.py b/backend/tests/test_iter_claims_received.py new file mode 100644 index 0000000..222a73e --- /dev/null +++ b/backend/tests/test_iter_claims_received.py @@ -0,0 +1,134 @@ +"""Tests that ``CycloneStore.iter_claims`` populates ``receivedAmount`` +from the matched ``Remittance.total_paid``. + +Before SP_Auth follow-up: every claim came back with +``receivedAmount: 0.0`` regardless of whether it had been paired with +a paid remittance, so the Dashboard's "Received" KPI was always $0 +even when claims had been paid and reconciled. + +The fix: ``iter_claims`` bulk-loads ``Remittance.total_paid`` for every +matched claim id in the result set (single SQL, no N+1) and stamps +the sum onto each claim dict. +""" + +from __future__ import annotations + +import json +from datetime import date, datetime, timezone +from decimal import Decimal + +import pytest + +from cyclone import db +from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance +from cyclone.store import store as global_store + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") + db._reset_for_tests() + db.init_db() + + +def _make_batch(s, batch_id: str) -> None: + s.add(Batch( + id=batch_id, + kind="837p", + input_filename="seed.edi", + parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), + totals_json={"total_claims": 3}, + validation_json={"passed": True, "warnings": [], "errors": []}, + raw_result_json={"_": "stub"}, + )) + + +def _make_claim(s, claim_id: str, batch_id: str, *, matched_remit_id: str | None = None) -> None: + s.add(Claim( + id=claim_id, + batch_id=batch_id, + patient_control_number=claim_id, + service_date_from=date(2026, 6, 1), + service_date_to=date(2026, 6, 1), + charge_amount=Decimal("200.00"), + provider_npi="1234567890", + payer_id="SKCO0", + state=ClaimState.PAID, + matched_remittance_id=matched_remit_id, + )) + + +def _make_remit(s, remit_id: str, batch_id: str, *, total_paid: Decimal) -> None: + s.add(Remittance( + id=remit_id, + batch_id=batch_id, + payer_claim_control_number=remit_id, + claim_id=None, + status_code="1", + total_charge=Decimal("200.00"), + total_paid=total_paid, + adjustment_amount=Decimal("0"), + received_at=datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc), + )) + + +def test_iter_claims_populates_received_amount_from_matched_remittance(): + """A matched claim should reflect its remittance's ``total_paid``.""" + with db.SessionLocal()() as s: + _make_batch(s, "b1") + _make_remit(s, "r1", "b1", total_paid=Decimal("180.00")) + _make_claim(s, "CLM-A", "b1", matched_remit_id="r1") + s.commit() + + items = global_store.iter_claims(limit=10) + by_id = {c["id"]: c for c in items} + assert by_id["CLM-A"]["receivedAmount"] == pytest.approx(180.00) + + +def test_iter_claims_unmatched_claim_has_zero_received(): + """Claims with no matched remittance still get 0.0, not stale data.""" + with db.SessionLocal()() as s: + _make_batch(s, "b1") + _make_claim(s, "CLM-U", "b1", matched_remit_id=None) + s.commit() + + items = global_store.iter_claims(limit=10) + by_id = {c["id"]: c for c in items} + assert by_id["CLM-U"]["receivedAmount"] == 0.0 + + +def test_iter_claims_handles_orphan_match_fk(): + """A claim with a stale ``matched_remittance_id`` whose remittance row + was deleted should default to 0.0 rather than blow up.""" + with db.SessionLocal()() as s: + _make_batch(s, "b1") + _make_claim(s, "CLM-ORPHAN", "b1", matched_remit_id="r-deleted") + s.commit() + + # No remittance row exists — the FK is dangling, which can happen + # if the remittance was deleted between match and now. + items = global_store.iter_claims(limit=10) + by_id = {c["id"]: c for c in items} + assert by_id["CLM-ORPHAN"]["receivedAmount"] == 0.0 + + +def test_iter_claims_bulk_loads_multiple_matches_in_one_pass(): + """All matched claims in a page reflect their distinct remittance + totals — the bulk load must aggregate per remittance id.""" + with db.SessionLocal()() as s: + _make_batch(s, "b1") + _make_remit(s, "r1", "b1", total_paid=Decimal("120.00")) + _make_remit(s, "r2", "b1", total_paid=Decimal("175.50")) + _make_remit(s, "r3", "b1", total_paid=Decimal("0")) + _make_claim(s, "CLM-1", "b1", matched_remit_id="r1") + _make_claim(s, "CLM-2", "b1", matched_remit_id="r2") + _make_claim(s, "CLM-3", "b1", matched_remit_id="r3") + _make_claim(s, "CLM-4", "b1", matched_remit_id=None) + s.commit() + + items = global_store.iter_claims(limit=10) + by_id = {c["id"]: c for c in items} + assert by_id["CLM-1"]["receivedAmount"] == pytest.approx(120.00) + assert by_id["CLM-2"]["receivedAmount"] == pytest.approx(175.50) + assert by_id["CLM-3"]["receivedAmount"] == pytest.approx(0.0) + assert by_id["CLM-4"]["receivedAmount"] == 0.0 \ No newline at end of file