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:
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user