feat(parsers+api+ui): expose CARC-labeled CAS adjustments (SP3 P2)
This commit is contained in:
@@ -417,6 +417,22 @@ async function listRemittances<T = unknown>(
|
||||
return (await res.json()) as PaginatedResponse<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one remittance with its labeled CAS `adjustments` array.
|
||||
* Throws `ApiError` on 404 so callers can branch on `.status`.
|
||||
*/
|
||||
async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(joinUrl(`/api/remittances/${encodeURIComponent(id)}`), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function listProviders<T = unknown>(
|
||||
params: ListProvidersParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
@@ -516,6 +532,7 @@ export const api = {
|
||||
getBatch,
|
||||
listClaims,
|
||||
listRemittances,
|
||||
getRemittance,
|
||||
listProviders,
|
||||
listActivity,
|
||||
listUnmatched,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Tell React this is an `act`-aware test environment so react-query's
|
||||
// internal state updates flush through without noisy console warnings.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Remittances } from "./Remittances";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
|
||||
// stub the method this page touches via the `useRemittances` hook.
|
||||
// `isConfigured: true` is critical — without it, `useRemittances` falls
|
||||
// back to the empty zustand store instead of calling our mocked API.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listRemittances: vi.fn(),
|
||||
getRemittance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimal `render` helper using react-dom/client + act(). Mirrors the
|
||||
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
|
||||
* file's comment for the rationale (project doesn't ship a DOM test
|
||||
* library yet, and adding one just for these tests would inflate the
|
||||
* dev-deps tree). Returns the rendered container so tests can assert
|
||||
* against the live DOM via `document.body.textContent`.
|
||||
*/
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
React.createElement(QueryClientProvider, { client: qc }, element)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush microtasks + react state updates until the predicate holds or we
|
||||
* time out. react-query's fetch resolves asynchronously, so we have to
|
||||
* await a tick before the rendered DOM reflects the mocked data.
|
||||
*/
|
||||
async function waitForText(
|
||||
text: string,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!document.body.textContent?.includes(text)) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`);
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("Remittances", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
||||
(
|
||||
api.listRemittances as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "PCN-1",
|
||||
claimId: "CLM-1",
|
||||
payerName: "Colorado Medicaid",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 60,
|
||||
receivedDate: "2026-06-19T12:00:00Z",
|
||||
checkNumber: "",
|
||||
status: "received",
|
||||
adjustments: [
|
||||
{
|
||||
group: "CO",
|
||||
reason: "45",
|
||||
label: "Charge exceeds fee schedule/maximum allowable",
|
||||
amount: 50,
|
||||
quantity: null,
|
||||
},
|
||||
{
|
||||
group: "PR",
|
||||
reason: "1",
|
||||
label: "Deductible amount",
|
||||
amount: 10,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
returned: 1,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-1");
|
||||
|
||||
// The chevron + "Adjustments" header should not yet be visible because
|
||||
// the row hasn't been expanded yet.
|
||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
||||
|
||||
// Expand the row by clicking on the remit ID cell. We click the parent
|
||||
// row by selecting the cell containing "PCN-1" and bubbling up.
|
||||
const cell = Array.from(document.querySelectorAll("td")).find(
|
||||
(td) => td.textContent === "PCN-1"
|
||||
);
|
||||
const row = cell?.closest("tr");
|
||||
expect(row).toBeTruthy();
|
||||
await act(async () => {
|
||||
(row as HTMLTableRowElement).click();
|
||||
});
|
||||
await waitForText("Adjustments (2)");
|
||||
|
||||
// Both CAS labels must surface (not the raw codes alone).
|
||||
expect(document.body.textContent).toContain(
|
||||
"Charge exceeds fee schedule/maximum allowable"
|
||||
);
|
||||
expect(document.body.textContent).toContain("Deductible amount");
|
||||
// Group/reason pills show the CARC code alongside.
|
||||
expect(document.body.textContent).toContain("CO-45");
|
||||
expect(document.body.textContent).toContain("PR-1");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
+121
-24
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -16,7 +17,7 @@ import { Pagination } from "@/components/ui/pagination";
|
||||
import { useRemittances } from "@/hooks/useRemittances";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RemittanceStatus } from "@/types";
|
||||
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -26,9 +27,36 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
||||
{ value: "reconciled", label: "Reconciled" },
|
||||
];
|
||||
|
||||
/**
|
||||
* One persisted CAS row, rendered as a "code — label" pair plus the
|
||||
* dollar amount. Lives inside the expanded detail row of a remit so
|
||||
* the operator can see exactly why the payer adjusted the claim.
|
||||
*/
|
||||
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-1.5 border-b border-border/40 last:border-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[11px] text-muted-foreground">
|
||||
{adj.group}-{adj.reason}
|
||||
{adj.quantity !== null ? (
|
||||
<span className="ml-2 text-muted-foreground/70">
|
||||
qty {adj.quantity}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[13px] text-foreground/90 truncate">{adj.label}</div>
|
||||
</div>
|
||||
<div className="display num text-[13px] tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{fmt.usdPrecise(adj.amount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Remittances() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||
sort: "receivedDate",
|
||||
@@ -46,6 +74,15 @@ export function Remittances() {
|
||||
{ paid: 0, adjustments: 0 }
|
||||
);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
@@ -117,6 +154,7 @@ export function Remittances() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
@@ -127,27 +165,86 @@ export function Remittances() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r) => (
|
||||
<TableRow key={`${r.id}-${dataUpdatedAt}`} className={cn("animate-row-flash")}>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.map((r) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
className={cn("animate-row-flash")}
|
||||
onClick={() =>
|
||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||
}
|
||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{hasAdjustments ? (
|
||||
isOpen ? (
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && hasAdjustments ? (
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||
className="bg-muted/30 hover:bg-muted/30"
|
||||
>
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Adjustments ({r.adjustments!.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
{r.adjustments!.map((adj, i) => (
|
||||
<AdjustmentRow
|
||||
key={`${adj.group}-${adj.reason}-${i}`}
|
||||
adj={adj}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="px-4 pb-4">
|
||||
@@ -163,4 +260,4 @@ export function Remittances() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,24 @@ export interface Remittance {
|
||||
status: RemittanceStatus;
|
||||
denialReason?: string | null;
|
||||
validationWarnings?: string[];
|
||||
/**
|
||||
* Persisted CAS adjustment rows, labeled by the backend via the bundled
|
||||
* CARC dictionary. Optional — the list endpoint omits it to keep the
|
||||
* payload small; the detail endpoint (`/api/remittances/{id}`) populates it.
|
||||
*/
|
||||
adjustments?: CasAdjustment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One Claim Adjustment Segment (CAS) row, labeled.
|
||||
* Source: backend `cyclone.parsers.cas_codes.reason_label(group, reason)`.
|
||||
*/
|
||||
export interface CasAdjustment {
|
||||
group: string; // "CO", "PR", "OA", "PI", "CR"
|
||||
reason: string; // "45", "1", etc.
|
||||
label: string; // "Charge exceeds fee schedule"
|
||||
amount: number;
|
||||
quantity: number | null;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
|
||||
Reference in New Issue
Block a user