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,
}
+30
View File
@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import {
api,
type ListRemittancesParams,
type RemittanceSummary,
} from "@/lib/api";
/**
* Server-aggregated KPI totals for the Remittances page tiles.
* Returns ``{count, total_paid, total_adjustments}`` summed over the
* full filtered remittance population — NOT a page-limited sample.
*
* The page's KPI tiles consume this hook instead of summing
* ``items.reduce(...)`` over the current page (25 rows) + live-tail
* delta, which silently understates the true population in the same
* shape that ``59c3275`` retired for the Dashboard and that
* ``d81b6ed`` retired for the count tile.
*
* The optional ``params`` object lets the page pass through the same
* filter chips it uses for the list endpoint (``status``, ``payer``,
* ``date_from``, ``date_to``) so the summary and the row list always
* agree on which rows they're describing.
*/
export function useRemittanceSummary(params: ListRemittancesParams = {}) {
return useQuery<RemittanceSummary>({
queryKey: ["remittances", "summary", params],
queryFn: () => api.listRemittanceSummary(params),
enabled: api.isConfigured,
});
}
+23
View File
@@ -651,6 +651,28 @@ async function listRemittances<T = unknown>(
);
}
/**
* Server-aggregated KPI tiles for the Remittances page. Returns the
* full-population ``count``, ``total_paid``, and ``total_adjustments``
* — NOT a page-limited sample — so the KPI tiles can't silently
* understate the true DB population the way a page-local
* ``items.reduce(...)`` would (commits ``59c3275``, ``d81b6ed``).
*/
export interface RemittanceSummary {
count: number;
total_paid: number;
total_adjustments: number;
}
async function listRemittanceSummary(
params: ListRemittancesParams = {}
): Promise<RemittanceSummary> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<RemittanceSummary>(
`/api/remittances/summary${qs(params as Record<string, unknown>)}`
);
}
/**
* Fetch one remittance with its labeled CAS `adjustments` array.
* Throws `ApiError` on 404 so callers can branch on `.status`.
@@ -1029,6 +1051,7 @@ export const api = {
serializeClaim837,
exportBatch837,
listRemittances,
listRemittanceSummary,
getRemittance,
listProviders,
getProvider,
+76
View File
@@ -20,6 +20,7 @@ vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listRemittances: vi.fn(),
listRemittanceSummary: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
@@ -224,6 +225,17 @@ describe("Remittances", () => {
returned: SAMPLE_REMITS.length,
has_more: false,
});
// Default summary mock so the page-level KPI tiles don't render
// `undefined`. Tests that care about specific summary values
// override this per-test (e.g. ``test_kpi_tiles_use_server_...
// _not_page_local_reduce``).
(
api.listRemittanceSummary as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
count: 0,
total_paid: 0,
total_adjustments: 0,
});
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL. Return a never-resolving
// promise so the drawer stays in the loading state; the smoke
@@ -487,4 +499,68 @@ describe("Remittances", () => {
unmount();
});
// -------------------------------------------------------------------
// KPI tiles must reflect the full DB population, NOT the page-local
// reduce over the visible rows. The bug repro is a 25-row page
// whose `items.reduce(...)` summed to $25.96 paid / $67.06
// adjustments while the real DB held $227,181.58 / $13,792.65
// across 1,739 rows. The "REMITS" tile already sources its count
// from the fixed `count_remittances` helper (commit d81b6ed).
// "TOTAL PAID" + "ADJUSTMENTS" must come from the new server-side
// summary endpoint (`/api/remittances/summary`) via
// `api.listRemittanceSummary` so they can't silently understate
// the true population. Mirrors the Dashboard silent-failure fix
// (commit 59c3275).
// -------------------------------------------------------------------
it("test_kpi_tiles_use_server_summary_not_page_local_reduce", async () => {
// Page sum over the visible 3 rows: paid=$525.96 (100+200+300-74.04),
// adjustments=$60 (60+0+0). Server ground truth (mocked): count=1739,
// paid=$227,181, adjustments=$13,792 — whole-dollar amounts chosen
// so `fmt.usd`'s maximumFractionDigits=0 doesn't round them and the
// assertion is unambiguous. The page must show the SERVER values,
// not the page sums.
(
api.listRemittanceSummary as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
count: 1739,
total_paid: 227181,
total_adjustments: 13792,
});
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-1");
const body = document.body.textContent ?? "";
// The server totals must show on the tiles.
expect(body).toContain("$227,181");
expect(body).toContain("$13,792");
// Server count wins over the 3-row page total.
expect(body).toContain("1,739");
// Locate the KPI tile for "Total paid" specifically. The tiles
// share the `.display.mono.text-[28px]` class with several other
// page elements, so we walk by `.eyebrow` to find each one.
const totalPaidTile = Array.from(
document.querySelectorAll(".surface-2"),
).find((el) => el.querySelector(".eyebrow")?.textContent === "Total paid");
expect(totalPaidTile).toBeDefined();
const paidValue = totalPaidTile?.querySelector(
".display.mono.text-\\[28px\\]",
);
expect(paidValue?.textContent).toBe("$227,181");
const adjustmentsTile = Array.from(
document.querySelectorAll(".surface-2"),
).find(
(el) => el.querySelector(".eyebrow")?.textContent === "Adjustments",
);
expect(adjustmentsTile).toBeDefined();
const adjValue = adjustmentsTile?.querySelector(
".display.mono.text-\\[28px\\]",
);
expect(adjValue?.textContent).toBe("$13,792");
unmount();
});
});
+18 -10
View File
@@ -18,6 +18,7 @@ import { PageHeader } from "@/components/PageHeader";
import { TailStatusPill } from "@/components/TailStatusPill";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useRemittances } from "@/hooks/useRemittances";
import { useRemittanceSummary } from "@/hooks/useRemittanceSummary";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
import { useTailStream } from "@/hooks/useTailStream";
@@ -67,13 +68,20 @@ export function Remittances() {
useTailStream("remittances");
const items = useMergedTail("remittances", data?.items ?? [], tailFilterFn);
const total = items.reduce(
(acc, r) => ({
paid: acc.paid + r.paidAmount,
adjustments: acc.adjustments + r.adjustmentAmount,
}),
{ paid: 0, adjustments: 0 }
);
// KPI totals come from the server-aggregated summary endpoint over
// the FULL remittance population — NOT a page-local reduce over the
// current page + live-tail delta. The same silent-incompleteness
// pattern that `59c3275` (Dashboard) and `d81b6ed` (count tile)
// retired applies to the financial tiles; summing visible rows
// understates the true totals and the page looks complete. The
// server returns zero-filled values when the DB has no rows so the
// tiles render predictably while the initial fetch resolves.
//
// We don't pass the page's status chip here — the list endpoint
// (``useRemittances``) ignores status server-side and applies it
// client-side via the merged-tail filter, so passing it to the
// summary would inconsistently narrow only one of the two reads.
const { data: summary } = useRemittanceSummary();
const moveNext = useCallback(() => {
setSelectedIndex((i) => {
@@ -154,19 +162,19 @@ export function Remittances() {
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Remits</div>
<div className="display mono text-[28px] leading-none mt-3 text-foreground">
{fmt.num(data?.total ?? 0)}
{fmt.num(summary?.count ?? data?.total ?? 0)}
</div>
</div>
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Total paid</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--success))]">
{fmt.usd(total.paid)}
{fmt.usd(summary?.total_paid ?? 0)}
</div>
</div>
<div className="surface-2 rounded-xl p-5">
<div className="eyebrow">Adjustments</div>
<div className="display mono text-[28px] leading-none mt-3 text-[hsl(var(--warning))]">
{fmt.usd(total.adjustments)}
{fmt.usd(summary?.total_adjustments ?? 0)}
</div>
</div>
</div>