From 14fcbca5f1da5d2239424d2824d37dc5b5241e18 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 29 Jun 2026 14:16:24 -0600 Subject: [PATCH] feat(sp27): server-aggregate Remittances KPIs (count, paid, adjustments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/src/cyclone/api.py | 31 ++ backend/src/cyclone/store.py | 41 ++- backend/tests/test_api_remittances_summary.py | 297 ++++++++++++++++++ src/hooks/useRemittanceSummary.ts | 30 ++ src/lib/api.ts | 23 ++ src/pages/Remittances.test.tsx | 76 +++++ src/pages/Remittances.tsx | 28 +- 7 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_api_remittances_summary.py create mode 100644 src/hooks/useRemittanceSummary.ts diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 1c4b674..db7bfac 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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, diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index 696beae..f607638 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -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: diff --git a/backend/tests/test_api_remittances_summary.py b/backend/tests/test_api_remittances_summary.py new file mode 100644 index 0000000..b72f3f5 --- /dev/null +++ b/backend/tests/test_api_remittances_summary.py @@ -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, + } diff --git a/src/hooks/useRemittanceSummary.ts b/src/hooks/useRemittanceSummary.ts new file mode 100644 index 0000000..414cbba --- /dev/null +++ b/src/hooks/useRemittanceSummary.ts @@ -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({ + queryKey: ["remittances", "summary", params], + queryFn: () => api.listRemittanceSummary(params), + enabled: api.isConfigured, + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 473b84a..151e488 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -651,6 +651,28 @@ async function listRemittances( ); } +/** + * 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 { + if (!isConfigured) throw notConfiguredError(); + return authedFetch( + `/api/remittances/summary${qs(params as Record)}` + ); +} + /** * 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, diff --git a/src/pages/Remittances.test.tsx b/src/pages/Remittances.test.tsx index fe1e964..c392558 100644 --- a/src/pages/Remittances.test.tsx +++ b/src/pages/Remittances.test.tsx @@ -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 + ).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 + ).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(); + }); }); \ No newline at end of file diff --git a/src/pages/Remittances.tsx b/src/pages/Remittances.tsx index 81860a4..0af6a7b 100644 --- a/src/pages/Remittances.tsx +++ b/src/pages/Remittances.tsx @@ -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() {
Remits
- {fmt.num(data?.total ?? 0)} + {fmt.num(summary?.count ?? data?.total ?? 0)}
Total paid
- {fmt.usd(total.paid)} + {fmt.usd(summary?.total_paid ?? 0)}
Adjustments
- {fmt.usd(total.adjustments)} + {fmt.usd(summary?.total_adjustments ?? 0)}