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