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 (commit 59c3275) and /api/remittances (commit
d81b6ed) 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:
Nora
2026-06-29 14:16:24 -06:00
parent d8841834dc
commit 14fcbca5f1
7 changed files with 515 additions and 11 deletions
+31
View File
@@ -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,
+40 -1
View File
@@ -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,
}