feat(sp27): server-aggregate Remittances KPIs (count, paid, adjustments)
The Remittances page's three KPI tiles — REMITS / TOTAL PAID / ADJUSTMENTS — were computed page-locally via items.reduce(...) over the merged tail of the current page + live delta. With a 100-row default limit, a 1,739-row population showed count=100, paid=$16,934, adjustments=$147 — silently understating reality because the page hadn't loaded the remaining rows yet. This change mirrors the silent-incompleteness fix that /api/dashboard/kpis (commit59c3275) and /api/remittances (commitd81b6ed) made for their tiles: * CycloneStore.summarize_remittances() iterates the full filtered remittance population (no limit) and returns {count, total_paid, total_adjustments}. Mirrors iter_remittances with limit=_ITER_UNBOUNDED. * GET /api/remittances/summary — server endpoint with the same filter parameters as /api/remittances. Registered BEFORE the /api/remittances/stream handler so FastAPI doesn't treat 'summary' as a stream sub-path. * api.listRemittanceSummary + useRemittanceSummary hook. * Remittances.tsx swaps off items.reduce, consumes the server summary. Tiles render the server totals so the values reflect the entire DB population, not the page-local sample. Verified live: /api/remittances/summary returns {count: 1739, total_paid: 227181.58, total_adjustments: 13792.65}, which matches DB ground truth exactly. Tests: 8 new backend tests in test_api_remittances_summary.py; 1 new frontend test in Remittances.test.tsx (kpi_tiles_use_server_summary_not _page_local_reduce) plus the page-level test for the zero-valued mock default.
This commit is contained in:
@@ -2241,6 +2241,37 @@ def list_remittances(
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/remittances/summary", dependencies=[Depends(matrix_gate)])
|
||||
def remittances_summary(
|
||||
batch_id: str | None = Query(None),
|
||||
payer: str | None = Query(None),
|
||||
claim_id: str | None = Query(None),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
) -> dict:
|
||||
"""Server-aggregated KPI tiles for the Remittances page.
|
||||
|
||||
Returns ``{count, total_paid, total_adjustments}`` over the
|
||||
full filtered remittance population — NOT a page-limited
|
||||
sample. The Remittances page consumes this for its "Total paid"
|
||||
and "Adjustments" tiles so they can't silently understate the
|
||||
true DB population the way a page-local ``items.reduce(...)``
|
||||
would. Mirrors the silent-incompleteness fix that
|
||||
``/api/dashboard/kpis`` (commit ``59c3275``) and
|
||||
``/api/remittances`` (commit ``d81b6ed``) made for their tiles.
|
||||
|
||||
Same filter parameters as ``/api/remittances``. Always returns
|
||||
a populated dict (``{"count": 0, "total_paid": 0,
|
||||
"total_adjustments": 0}`` when no rows match) so the frontend
|
||||
can render the tiles directly without a loading-vs-empty
|
||||
branch.
|
||||
"""
|
||||
return store.summarize_remittances(
|
||||
batch_id=batch_id, payer=payer, claim_id=claim_id,
|
||||
date_from=date_from, date_to=date_to,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/remittances/stream", dependencies=[Depends(matrix_gate)])
|
||||
async def remittances_stream(
|
||||
request: Request,
|
||||
|
||||
@@ -192,7 +192,7 @@ def _claim_837_row(claim: ClaimOutput, batch_id: str) -> Claim:
|
||||
id=claim.claim_id,
|
||||
batch_id=batch_id,
|
||||
# SP27 Task 17: Claim.patient_control_number must hold the CLM01
|
||||
# claim_submitter's_identifier the 837 sent — that's the value the
|
||||
# claim_submittr's_identifier the 837 sent — that's the value the
|
||||
# 835 echoes in CLP01, which the reconcile matcher joins on
|
||||
# (reconcile.py:by_pcn), and what 999 / 277CA ACK lookups also
|
||||
# use to cross-reference the original claim. Storing
|
||||
@@ -1826,6 +1826,45 @@ class CycloneStore:
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
def summarize_remittances(
|
||||
self,
|
||||
*,
|
||||
batch_id: str | None = None,
|
||||
payer: str | None = None,
|
||||
claim_id: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> dict:
|
||||
"""Return ``{count, total_paid, total_adjustments}`` summed
|
||||
over the remittance population that ``iter_remittances``
|
||||
would return under the same filters.
|
||||
|
||||
Same filter parameters as :meth:`iter_remittances` (excluding
|
||||
``sort``/``order``/``limit``/``offset``). The endpoint that
|
||||
relies on this helper —
|
||||
``GET /api/remittances/summary`` — backs the Remittances
|
||||
page's KPI tiles so a page-local sum (25 rows + live-tail
|
||||
delta) can never silently understate the true population.
|
||||
Mirrors Dashboard's server-aggregated ``/api/dashboard/kpis``
|
||||
(commit ``59c3275``) and the ``count_remittances`` shape
|
||||
from commit ``d81b6ed``.
|
||||
"""
|
||||
rows = self.iter_remittances(
|
||||
batch_id=batch_id, payer=payer, claim_id=claim_id,
|
||||
date_from=date_from, date_to=date_to,
|
||||
sort=None, order="desc", limit=_ITER_UNBOUNDED, offset=0,
|
||||
)
|
||||
total_paid = 0.0
|
||||
total_adjustments = 0.0
|
||||
for r in rows:
|
||||
total_paid += float(r.get("paidAmount") or 0)
|
||||
total_adjustments += float(r.get("adjustmentAmount") or 0)
|
||||
return {
|
||||
"count": len(rows),
|
||||
"total_paid": total_paid,
|
||||
"total_adjustments": total_adjustments,
|
||||
}
|
||||
|
||||
def distinct_providers(self) -> list[dict]:
|
||||
"""Group claims by NPI and return one row per provider."""
|
||||
with db.SessionLocal()() as s:
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Tests for ``GET /api/remittances/summary`` (server-aggregated KPI
|
||||
backend for the Remittances page tiles).
|
||||
|
||||
Anomaly recap (see ``docs/superpowers/specs/2026-06-29-cyclone-...
|
||||
-design.md`` if it lands later):
|
||||
|
||||
* The Remittances page's "Total paid" + "Adjustments" KPI tiles
|
||||
previously summed ``items.reduce(...)`` over the *current page* (25
|
||||
rows) plus whatever live-tail events had arrived in the session.
|
||||
* For an empty-looking page with most-paid=0 rows, the UI rendered
|
||||
e.g. ``$16,934`` paid / ``$147`` adjustments — neither the page
|
||||
total ($1,334 / $143) nor the true DB total ($227,181 / $13,792).
|
||||
Same silent-incompleteness pattern the count bug retired in
|
||||
``d81b6ed``: the page looked complete but the numbers were wrong.
|
||||
|
||||
The fix adds a server-aggregated summary endpoint — ``count + paid
|
||||
+ adjustments`` summed over the full persisted row set with the same
|
||||
filter pipeline as ``/api/remittances``. ``Remittances.tsx`` swaps
|
||||
``items.reduce(...)`` for the new hook so the tiles reflect the true
|
||||
DB population regardless of dataset size.
|
||||
|
||||
The covered cases match ``test_list_endpoint_counts.py`` /
|
||||
``test_acks_aggregates.py`` for shape consistency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.db import Batch, Remittance
|
||||
from cyclone.store import store as global_store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_remit(
|
||||
s,
|
||||
remit_id: str,
|
||||
batch_id: str,
|
||||
*,
|
||||
received_at: datetime,
|
||||
total_paid: Decimal = Decimal("100.00"),
|
||||
adjustment_amount: Decimal = Decimal("0"),
|
||||
claim_id: str | None = None,
|
||||
payer_name: str = "Colorado Medicaid",
|
||||
status_code: str = "1",
|
||||
) -> None:
|
||||
# The remittance's batch row carries ``raw_result_json`` with the
|
||||
# payer name because ``iter_remittances`` reads payer name from
|
||||
# there. We stage a fresh ``Batch`` row per call so unique
|
||||
# ``payer_name`` values for tests like the per-payer filter case
|
||||
# don't collide — the existing pattern from
|
||||
# ``test_list_endpoint_counts.py:88``.
|
||||
s.add(Batch(
|
||||
id=batch_id,
|
||||
kind="835",
|
||||
input_filename=f"{remit_id}.edi",
|
||||
parsed_at=received_at,
|
||||
totals_json={"total_claims": 1},
|
||||
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||
raw_result_json={"payer": {"name": payer_name}},
|
||||
))
|
||||
s.add(Remittance(
|
||||
id=remit_id,
|
||||
batch_id=batch_id,
|
||||
payer_claim_control_number=remit_id,
|
||||
claim_id=claim_id,
|
||||
status_code=status_code,
|
||||
total_charge=Decimal("100.00"),
|
||||
total_paid=total_paid,
|
||||
adjustment_amount=adjustment_amount,
|
||||
received_at=received_at,
|
||||
))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Module-level: summarize_remittances
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_summarize_remittances_zero_when_empty():
|
||||
assert global_store.summarize_remittances() == {
|
||||
"count": 0,
|
||||
"total_paid": 0.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_summarize_remittances_unfiltered_returns_full_population():
|
||||
"""Regression: with 5 remits seeded, the OLD ``items.reduce(...)``
|
||||
page-local sum was per-page (25 rows); the new helper must
|
||||
aggregate over all 5."""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
# 5 remits, paid=10/20/30/40/50 → sum=150, adj all 0.
|
||||
for i, amt in enumerate([10, 20, 30, 40, 50]):
|
||||
_seed_remit(
|
||||
s, f"RMT-SUM-{i:04d}", f"b-sum-pop-{i}",
|
||||
received_at=base + timedelta(minutes=i),
|
||||
total_paid=Decimal(amt),
|
||||
adjustment_amount=Decimal("0"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
assert global_store.summarize_remittances() == {
|
||||
"count": 5,
|
||||
"total_paid": 150.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_summarize_remittances_sums_adjustments_across_rows():
|
||||
"""Adjustments accumulate independently from paid."""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
_seed_remit(
|
||||
s, "RMT-A", "b-sum-adj-A",
|
||||
received_at=base, total_paid=Decimal("25.96"),
|
||||
adjustment_amount=Decimal("16.87"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-B", "b-sum-adj-B",
|
||||
received_at=base + timedelta(minutes=1),
|
||||
total_paid=Decimal("0"), adjustment_amount=Decimal("50.19"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-C", "b-sum-adj-C",
|
||||
received_at=base + timedelta(minutes=2),
|
||||
total_paid=Decimal("0"), adjustment_amount=Decimal("9.95"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
assert global_store.summarize_remittances() == {
|
||||
"count": 3,
|
||||
"total_paid": 25.96,
|
||||
"total_adjustments": 77.01, # 16.87 + 50.19 + 9.95
|
||||
}
|
||||
|
||||
|
||||
def test_summarize_remittances_filters_by_claim_id():
|
||||
"""claim_id filter narrows the summary the same way it narrows
|
||||
iter_remittances (DB-side WHERE remittance.claim_id = :claim_id).
|
||||
"""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
_seed_remit(
|
||||
s, "RMT-X", "b-sum-cid-X",
|
||||
received_at=base, claim_id="CLM-1",
|
||||
total_paid=Decimal("10"), adjustment_amount=Decimal("0"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-Y", "b-sum-cid-Y",
|
||||
received_at=base, claim_id="CLM-2",
|
||||
total_paid=Decimal("20"), adjustment_amount=Decimal("1"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-Z", "b-sum-cid-Z",
|
||||
received_at=base, claim_id=None,
|
||||
total_paid=Decimal("30"), adjustment_amount=Decimal("2"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
assert global_store.summarize_remittances(claim_id="CLM-1") == {
|
||||
"count": 1,
|
||||
"total_paid": 10.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
assert global_store.summarize_remittances(claim_id="CLM-2") == {
|
||||
"count": 1,
|
||||
"total_paid": 20.0,
|
||||
"total_adjustments": 1.0,
|
||||
}
|
||||
assert global_store.summarize_remittances(claim_id="CLM-missing") == {
|
||||
"count": 0,
|
||||
"total_paid": 0.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_summarize_remittances_filters_by_payer_exact_match():
|
||||
"""``payer`` filter is in-memory + case-sensitive exact match —
|
||||
the helper must apply it the same way ``iter_remittances`` does
|
||||
so summary and list views agree under the same chip."""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
_seed_remit(
|
||||
s, "RMT-CO-1", "b-sum-payer-co-1",
|
||||
received_at=base, payer_name="Colorado Medicaid",
|
||||
total_paid=Decimal("7"), adjustment_amount=Decimal("0.50"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-CO-2", "b-sum-payer-co-2",
|
||||
received_at=base + timedelta(minutes=1),
|
||||
payer_name="Colorado Medicaid",
|
||||
total_paid=Decimal("13"), adjustment_amount=Decimal("0.25"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-AZ-1", "b-sum-payer-az-1",
|
||||
received_at=base + timedelta(minutes=2),
|
||||
payer_name="Arizona Medicaid",
|
||||
total_paid=Decimal("99"), adjustment_amount=Decimal("0"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
assert global_store.summarize_remittances(payer="Colorado Medicaid") == {
|
||||
"count": 2,
|
||||
"total_paid": 20.0,
|
||||
"total_adjustments": 0.75,
|
||||
}
|
||||
assert global_store.summarize_remittances(payer="Arizona Medicaid") == {
|
||||
"count": 1,
|
||||
"total_paid": 99.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
assert global_store.summarize_remittances(payer="colorado medicaid") == {
|
||||
"count": 0,
|
||||
"total_paid": 0.0,
|
||||
"total_adjustments": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTTP: /api/remittances/summary
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_api_remittances_summary_empty(client: TestClient):
|
||||
"""Empty DB → zero totals, well-formed response."""
|
||||
resp = client.get("/api/remittances/summary")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"count": 0,
|
||||
"total_paid": 0,
|
||||
"total_adjustments": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_api_remittances_summary_full_population(client: TestClient):
|
||||
"""Bug repro: seed 5 remits with known amounts, assert the
|
||||
endpoint reports the FULL sum, not a 25-row sample.
|
||||
"""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
for i, amt in enumerate([100, 200, 300, 400, 500]):
|
||||
_seed_remit(
|
||||
s, f"RMT-HTTP-SUM-{i}", f"b-http-sum-{i}",
|
||||
received_at=base + timedelta(minutes=i),
|
||||
total_paid=Decimal(amt),
|
||||
adjustment_amount=Decimal("5"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
resp = client.get("/api/remittances/summary")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body == {
|
||||
"count": 5,
|
||||
"total_paid": 1500, # 100+200+300+400+500
|
||||
"total_adjustments": 25, # 5*5
|
||||
}
|
||||
|
||||
|
||||
def test_api_remittances_summary_respects_claim_id_filter(client: TestClient):
|
||||
"""Filter narrows both count and sums."""
|
||||
base = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||
with db.SessionLocal()() as s:
|
||||
_seed_remit(
|
||||
s, "RMT-F-1", "b-http-cid-1",
|
||||
received_at=base, claim_id="CLM-F1",
|
||||
total_paid=Decimal("11"), adjustment_amount=Decimal("1"),
|
||||
)
|
||||
_seed_remit(
|
||||
s, "RMT-F-2", "b-http-cid-2",
|
||||
received_at=base, claim_id="CLM-F2",
|
||||
total_paid=Decimal("22"), adjustment_amount=Decimal("2"),
|
||||
)
|
||||
s.commit()
|
||||
|
||||
resp = client.get(
|
||||
"/api/remittances/summary",
|
||||
params={"claim_id": "CLM-F1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {
|
||||
"count": 1,
|
||||
"total_paid": 11,
|
||||
"total_adjustments": 1,
|
||||
}
|
||||
Reference in New Issue
Block a user