Populate receivedAmount from matched Remittance.total_paid

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.
This commit is contained in:
Nora
2026-06-22 17:46:47 -06:00
parent 414d2eb722
commit add6e982a4
3 changed files with 257 additions and 22 deletions
+74 -15
View File
@@ -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)
+47 -5
View File
@@ -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,9 +2170,7 @@ 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)
# None check so we don't blow up on a stale FK.
if paired_remit is not None:
paired_remit.claim_id = None
@@ -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,
+134
View File
@@ -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