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
+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>