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:
@@ -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