feat(frontend): ClaimDetail type + api.getClaimDetail
This commit is contained in:
+106
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { api } from "./api";
|
import { ApiError, api } from "./api";
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
@@ -70,4 +70,109 @@ describe("api GET helpers", () => {
|
|||||||
const called = mockFetch.mock.calls[0][0] as string;
|
const called = mockFetch.mock.calls[0][0] as string;
|
||||||
expect(called).toContain("/api/batches/abc");
|
expect(called).toContain("/api/batches/abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("getClaimDetail hits /api/claims/{id} and returns the parsed body", async () => {
|
||||||
|
// Minimal valid ClaimDetail shape — every spec-mandated key present,
|
||||||
|
// including the optional `matchedRemittance: null` for an unpaired
|
||||||
|
// claim (the dominant UI render path).
|
||||||
|
const body = {
|
||||||
|
id: "CLM-1",
|
||||||
|
batchId: "B-1",
|
||||||
|
state: "submitted",
|
||||||
|
stateLabel: "Submitted",
|
||||||
|
billedAmount: 123.45,
|
||||||
|
patientName: "Jane Doe",
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
providerName: "Acme Clinic",
|
||||||
|
payerName: "Medicaid",
|
||||||
|
payerId: "MEDCO",
|
||||||
|
submissionDate: "2026-01-15T00:00:00Z",
|
||||||
|
serviceDateFrom: "2026-01-10",
|
||||||
|
serviceDateTo: "2026-01-10",
|
||||||
|
parsedAt: "2026-01-15T00:00:01Z",
|
||||||
|
diagnoses: [{ code: "E11.9", qualifier: "ABK" }],
|
||||||
|
serviceLines: [
|
||||||
|
{
|
||||||
|
lineNumber: 1,
|
||||||
|
procedureQualifier: "HC",
|
||||||
|
procedureCode: "99213",
|
||||||
|
modifiers: [],
|
||||||
|
charge: 100.0,
|
||||||
|
units: 1,
|
||||||
|
unitType: "UN",
|
||||||
|
serviceDate: "2026-01-10",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parties: {
|
||||||
|
billingProvider: {
|
||||||
|
name: "Acme Clinic",
|
||||||
|
npi: "1234567890",
|
||||||
|
taxId: "12-3456789",
|
||||||
|
address: {
|
||||||
|
line1: "123 Main St",
|
||||||
|
line2: null,
|
||||||
|
city: "Denver",
|
||||||
|
state: "CO",
|
||||||
|
zip: "80202",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscriber: {
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Doe",
|
||||||
|
memberId: "M-1",
|
||||||
|
dob: null,
|
||||||
|
gender: "F",
|
||||||
|
},
|
||||||
|
payer: { name: "Medicaid", id: "MEDCO" },
|
||||||
|
},
|
||||||
|
validation: { passed: true, errors: [], warnings: [] },
|
||||||
|
rawSegments: [["ISA*00*"]],
|
||||||
|
matchedRemittance: null,
|
||||||
|
stateHistory: [
|
||||||
|
{
|
||||||
|
kind: "claim_submitted",
|
||||||
|
ts: "2026-01-15T00:00:00Z",
|
||||||
|
batchId: "B-1",
|
||||||
|
remittanceId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const out = await api.getClaimDetail("CLM-1");
|
||||||
|
expect(out).toEqual(body);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
const called = mockFetch.mock.calls[0][0] as string;
|
||||||
|
// URL must include the encoded id, no trailing query string.
|
||||||
|
expect(called).toContain("/api/claims/CLM-1");
|
||||||
|
expect(called).not.toContain("?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getClaimDetail throws an ApiError on 404", async () => {
|
||||||
|
// The backend raises `{"error":"Not found","detail":"Claim X not found"}`
|
||||||
|
// on missing ids (see api.py:get_claim_detail_endpoint). The client
|
||||||
|
// must surface it as an ApiError so the drawer can distinguish
|
||||||
|
// "doesn't exist" from a transient fetch failure (spec §3.4).
|
||||||
|
mockFetch.mockResolvedValueOnce(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await api.getClaimDetail("ghost");
|
||||||
|
} catch (err) {
|
||||||
|
caught = err;
|
||||||
|
}
|
||||||
|
expect(caught).toBeInstanceOf(ApiError);
|
||||||
|
expect((caught as ApiError).status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
Ack,
|
Ack,
|
||||||
|
ClaimDetail,
|
||||||
ClaimOutput,
|
ClaimOutput,
|
||||||
ClaimPayment,
|
ClaimPayment,
|
||||||
Envelope,
|
Envelope,
|
||||||
@@ -451,6 +452,29 @@ async function listClaims<T = unknown>(
|
|||||||
return (await res.json()) as PaginatedResponse<T>;
|
return (await res.json()) as PaginatedResponse<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one claim with the full SP4 detail-drawer context (header, state,
|
||||||
|
* parties, validation, service lines, diagnoses, raw segments, recent
|
||||||
|
* ``stateHistory``, and a populated ``matchedRemittance`` when paired).
|
||||||
|
*
|
||||||
|
* Drives ``GET /api/claims/{claim_id}``. Throws ``ApiError`` on non-2xx —
|
||||||
|
* including 404, which the spec §3.4 calls out as a distinct "claim
|
||||||
|
* doesn't exist" state in the drawer (separate from a transient fetch
|
||||||
|
* failure). Callers should branch on ``error.status === 404`` to render
|
||||||
|
* the not-found state instead of the generic error toast.
|
||||||
|
*/
|
||||||
|
async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
||||||
|
if (!isConfigured) throw notConfiguredError();
|
||||||
|
const res = await fetch(joinUrl(`/api/claims/${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 ClaimDetail;
|
||||||
|
}
|
||||||
|
|
||||||
async function listRemittances<T = unknown>(
|
async function listRemittances<T = unknown>(
|
||||||
params: ListRemittancesParams
|
params: ListRemittancesParams
|
||||||
): Promise<PaginatedResponse<T>> {
|
): Promise<PaginatedResponse<T>> {
|
||||||
@@ -649,6 +673,7 @@ export const api = {
|
|||||||
listBatches,
|
listBatches,
|
||||||
getBatch,
|
getBatch,
|
||||||
listClaims,
|
listClaims,
|
||||||
|
getClaimDetail,
|
||||||
listRemittances,
|
listRemittances,
|
||||||
getRemittance,
|
getRemittance,
|
||||||
listProviders,
|
listProviders,
|
||||||
|
|||||||
@@ -418,3 +418,128 @@ export interface Ack {
|
|||||||
ackCode: "A" | "E" | "R" | "P";
|
ackCode: "A" | "E" | "R" | "P";
|
||||||
parsedAt: string;
|
parsedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SP4 claim detail drawer types (Task 4).
|
||||||
|
// Mirrors the JSON shape of `GET /api/claims/{claim_id}` 1:1 — camelCase
|
||||||
|
// keys, numeric money fields (coerced from Decimal on the backend), ISO
|
||||||
|
// timestamps with a trailing `Z`. Distinct from the parser-aligned
|
||||||
|
// snake_case types above (e.g. ``BillingProvider``, ``ServiceLine``) which
|
||||||
|
// describe the EDI parse tree; these describe the UI-ready detail payload.
|
||||||
|
// The ``ClaimDetail*`` prefix keeps the two flavors side-by-side without
|
||||||
|
// a TypeScript duplicate-identifier collision.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ClaimDetailDiagnosis {
|
||||||
|
code: string;
|
||||||
|
qualifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailServiceLine {
|
||||||
|
lineNumber: number;
|
||||||
|
procedureQualifier: string;
|
||||||
|
procedureCode: string;
|
||||||
|
modifiers: string[];
|
||||||
|
charge: number;
|
||||||
|
units: number | null;
|
||||||
|
unitType: string | null;
|
||||||
|
serviceDate: string | null; // ISO date YYYY-MM-DD or null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend can return ``{}`` for a missing address (see
|
||||||
|
* ``store._address_to_ui``); callers that want a populated address must
|
||||||
|
* branch on ``Object.keys(addr).length === 0`` before reading fields.
|
||||||
|
*/
|
||||||
|
export interface ClaimDetailAddress {
|
||||||
|
line1: string;
|
||||||
|
line2: string | null;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailBillingProvider {
|
||||||
|
name: string;
|
||||||
|
npi: string;
|
||||||
|
taxId: string;
|
||||||
|
address: ClaimDetailAddress | Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailSubscriber {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
memberId: string;
|
||||||
|
dob: string | null;
|
||||||
|
gender: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailPayer {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailParties {
|
||||||
|
billingProvider: ClaimDetailBillingProvider;
|
||||||
|
subscriber: ClaimDetailSubscriber;
|
||||||
|
payer: ClaimDetailPayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailValidationIssue {
|
||||||
|
rule: string;
|
||||||
|
severity: "error" | "warning";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailValidation {
|
||||||
|
passed: boolean;
|
||||||
|
errors: ClaimDetailValidationIssue[];
|
||||||
|
warnings: ClaimDetailValidationIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailMatchedRemittance {
|
||||||
|
id: string;
|
||||||
|
totalPaid: number;
|
||||||
|
/** Mirrors the same `"received" | "reconciled"` mapping as the list endpoint. */
|
||||||
|
status: string;
|
||||||
|
receivedAt: string; // ISO Z
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimDetailStateHistoryEvent {
|
||||||
|
kind: string;
|
||||||
|
ts: string; // ISO Z (most-recent-first, capped at 50 by the backend)
|
||||||
|
batchId: string | null;
|
||||||
|
remittanceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One claim with the full drawer context: header, state, parties,
|
||||||
|
* validation, service lines, diagnoses, raw segments, recent
|
||||||
|
* ``stateHistory`` (most-recent-first, capped at 50), and a populated
|
||||||
|
* ``matchedRemittance`` block when the claim has been paired with an
|
||||||
|
* ERA. Mirrors ``CycloneStore.get_claim_detail`` (backend/src/cyclone/store.py)
|
||||||
|
* and the ``GET /api/claims/{claim_id}`` endpoint contract.
|
||||||
|
*/
|
||||||
|
export interface ClaimDetail {
|
||||||
|
id: string;
|
||||||
|
batchId: string;
|
||||||
|
state: string;
|
||||||
|
stateLabel: string;
|
||||||
|
billedAmount: number;
|
||||||
|
patientName: string;
|
||||||
|
providerNpi: string;
|
||||||
|
providerName: string;
|
||||||
|
payerName: string;
|
||||||
|
payerId: string;
|
||||||
|
submissionDate: string; // ISO Z
|
||||||
|
serviceDateFrom: string | null;
|
||||||
|
serviceDateTo: string | null;
|
||||||
|
parsedAt: string; // ISO Z
|
||||||
|
diagnoses: ClaimDetailDiagnosis[];
|
||||||
|
serviceLines: ClaimDetailServiceLine[];
|
||||||
|
parties: ClaimDetailParties;
|
||||||
|
validation: ClaimDetailValidation;
|
||||||
|
rawSegments: string[][];
|
||||||
|
matchedRemittance: ClaimDetailMatchedRemittance | null;
|
||||||
|
stateHistory: ClaimDetailStateHistoryEvent[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user