import { useState, useCallback, createContext, useContext, ReactNode, type SetStateAction } from 'react'; import type { Bundle, Invoice, PaymentReconciliation } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { buildInvoiceResource, type InvoiceDraft } from '../utils/invoiceTransform'; import { fetchAllBundlePages } from '../utils/fetchAllBundlePages'; import { searchPageSize } from '../utils/pageSizeOverride'; import { IncompleteLedgerError } from '../utils/paymentModel'; import { markSearchStarted, overlayHeld, overlayHeldPage, refersToPatient, sameViewerSince, startSearch } from '../utils/writeThroughHold'; import { useCacheOwner } from '../utils/cacheOwner'; import { isBillOf, owningPracticeOf } from '../utils/billOwner'; const API_URL = environment.fhirBaseApi; // Staff-recorded payments are served by the org-authorization API, not the base one. const PAYMENT_API_URL = environment.orgAuthorizationApi; export interface InvoicePatientCandidate { id: string; name?: string; birthDate?: string; } export type InvoicePatientLookup = { id: string } | { name?: string; birthDate?: string }; /** Only `confirmed` means the payment's record exists; any other answer leaves it unconfirmed. */ export interface RecordedPayment { outcome: 'confirmed'; paid: boolean; invoiceStatus?: string; remainingCents: number; } export const useInvoiceService = () => { const { get, post, put } = useHttpService(); const [invoiceList, setInvoiceListState] = useState([]); // The list a screen published belongs to the viewer who published it; readers by id use it only while current. const cacheOwner = useCacheOwner('Invoice', () => setInvoiceListState([])); const setInvoiceList = useCallback((list: SetStateAction) => { setInvoiceListState(list); cacheOwner.claim('viewer'); }, [cacheOwner.claim]); // Org-wide, Paginator-shaped. Server org-scopes the search from the trusted auth-context org // (via _tag), so the client must not send an org/issuer param. const fetchInvoices = useCallback( async (pageSize: number, pageParam?: string, query?: string, status?: string, dateSearch?: string[]): Promise> => { const started = startSearch(); const params = new URLSearchParams(); params.set('_count', String(pageSize)); // Newest first by invoice date. The server dates every invoice at creation and again at issue, // so HealthLake orders every page and the Overview's first ten the same way. params.set('_sort', '-date'); if (pageParam) params.set('page', pageParam); if (query) params.set('identifier', query); // Server-side status filter (datastore indexes Invoice by `status`). Callers that pass none - // e.g. the patient portal and the unfiltered "All" list - send the same request as before. if (status) params.set('status', status); // Issued within a report's period, bounded on the report's clock. for (const bound of dateSearch ?? []) params.append('date', bound); // SEARCH-BOUNDED: this one is single-page ON PURPOSE and must stay that way. It is the // backing read for a UI Paginator - the CALLER owns the cursor, passes `pageParam` back in, // and renders one page at a time. Wrapping it in a paginator would make "next page" fetch // every page, which is the opposite of what the screen asks for. The bound is not "the // result cannot exceed one page"; it is "one page is the caller's unit of work". return overlayHeldPage('Invoice', markSearchStarted(await get>(`${API_URL}/Invoice?${params.toString()}`), started)) as Bundle; }, [get], ); // Patient-scoped - the server REQUIRES the `subject` param (Phase 1 Invoice/get.ts rejects an unscoped // search to prevent enumerating the whole org). const fetchInvoicesForPatient = useCallback( async (patientId: string): Promise => { // No `_count` was stated at all, so this returned whatever one server page held - of a // patient's OWN bills, on the screen where they are asked to pay them. const started = startSearch(); const { resources, truncated } = await fetchAllBundlePages((pageParam) => { const params = new URLSearchParams(); params.set('subject', `Patient/${patientId}`); params.set('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Invoice?${params.toString()}`); }); // THE HONEST GAP, WRITTEN DOWN RATHER THAN PAPERED OVER: this returns an ARRAY, so there is // nowhere to carry `truncated` to the screen - the caller cannot be told the list is short. // Hitting 50 pages of invoices for ONE patient is not a realistic bill count, but "surely not" // is the reasoning that produced this whole card, so the fact is at least made loud here. // Fixing it properly means changing this signature and its callers, which is its own change. if (truncated) { console.warn( `INVOICE_LIST_TRUNCATED: patient ${patientId} has more invoices than this read follows. ` + 'The list shown is incomplete and the caller has no way to know (card 428).', ); } if (!sameViewerSince(started)) return []; return overlayHeld('Invoice', resources, (invoice) => refersToPatient(invoice, patientId), Date.now(), !truncated, started); }, [get], ); // Cheap status count for the Overview dashboard. FHIR `_summary=count` returns a Bundle carrying only // `total` (no `entry` rows), so this is a single small request per status - never a full/paginated // fetch. The server org-scopes the search from the trusted auth-context org (via _tag), same as // fetchInvoices, so no org/issuer param is sent. Omitting `status` counts every invoice in the org. const countInvoices = useCallback(async (status?: string): Promise => { const params = new URLSearchParams(); params.set('_summary', 'count'); if (status) params.set('status', status); // SEARCH-BOUNDED: `_summary=count` returns a Bundle carrying ONLY `total` - the server sends no // `entry` rows and no `next` link, so there is literally nothing to page through. Wrapping this // would also break it: the paginator rebuilds `total` from the entries it collected, which is // zero here, so a correct count would come back as 0. const bundle = await get>(`${API_URL}/Invoice?${params.toString()}`); return bundle.total ?? 0; }, [get]); // fresh skips the list cache, for a read right after the server changed the invoice. const fetchInvoice = useCallback(async (id: string, fresh = false): Promise => { const cached = fresh || !cacheOwner.isCurrent(cacheOwner.claimed) ? undefined : invoiceList.find(i => i.id === id); if (cached) return cached; return get(`${API_URL}/Invoice/${id}`); }, [get, invoiceList, cacheOwner.isCurrent]); // taxRatePercent defaults to 0 (no tax) - existing callers that don't pass it keep building // untaxed invoices exactly as before. const createInvoice = useCallback(async (draft: InvoiceDraft, taxRatePercent?: number): Promise => { return post(`${API_URL}/Invoice`, buildInvoiceResource(draft, taxRatePercent)); }, [post]); // Who a new invoice is for, on the same gate as creating one: this practice's patients by id, name or // date of birth, returning only those three fields. The chart search stays closed to billing staff. const findInvoicePatients = useCallback(async (lookup: InvoicePatientLookup): Promise => { const params = new URLSearchParams(); if ('id' in lookup) params.append('id', lookup.id); else { if (lookup.name) params.append('name', lookup.name); if (lookup.birthDate) params.append('birthDate', lookup.birthDate); } const body = await get<{ candidates?: InvoicePatientCandidate[] }>(`${API_URL}/invoice-patient-candidates?${params.toString()}`); return body?.candidates ?? []; }, [get]); // PUT that changes ONLY `status` - the server's immutability guard rejects any other field change on a // non-draft invoice with 409, so this must never send anything but `{...invoice, status }`. const setInvoiceStatus = useCallback(async (invoice: Invoice, status: Invoice['status']): Promise => { return put(`${API_URL}/Invoice/${invoice.id}`, { ...invoice, status }); }, [put]); // PUT a still-draft invoice (edit lines/total). Unlike setInvoiceStatus this may change any field, since // the server's immutability guard only applies once an invoice leaves `draft`. const updateInvoiceDraft = useCallback(async (invoice: Invoice): Promise => { return put(`${API_URL}/Invoice/${invoice.id}`, invoice); }, [put]); // Retroactive allow-partial-pay toggle (Task 6): a narrow sibling PUT that touches ONLY the one // payment-policy extension, so it works on an ALREADY-ISSUED invoice - unlike updateInvoiceDraft/ // setInvoiceStatus, which go through Invoice/{id} and are subject to the server's issued-invoice // immutability guard. Server-side: PUT /R4/invoice-partial-pay/{id}, body { allowPartialPay }. const setInvoicePartialPay = useCallback(async (invoiceId: string, allowPartialPay: boolean): Promise => { return put(`${API_URL}/invoice-partial-pay/${invoiceId}`, { allowPartialPay }); }, [put]); // An invoice's captured-payment ledger (Task 7): the server records each partial/full payment as a // FHIR PaymentReconciliation and exposes this searchset on the same gateway as Invoice reads. Mirrors // fetchInvoicesForPatient's Bundle-unwrap. Returns [] for an empty/absent Bundle rather than throwing. const fetchInvoicePayments = useCallback( async (invoiceId: string): Promise => { // A partially-paid invoice accrues one PaymentReconciliation per payment, and this ledger is // what the running balance is computed from - so a short read does not just hide a row, it // makes the patient look like they owe money they have already paid. // A ledger that stopped at the page bound, or met a page that is not a searchset, a payment-less entry or // a next link with no cursor, is short: it is refused as a whole rather than summed. let whole = true; const { resources, truncated } = await fetchAllBundlePages(async (pageParam) => { const params = new URLSearchParams(); params.set('invoice', invoiceId); params.set('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); const page = await get>(`${API_URL}/PaymentReconciliation?${params.toString()}`); if (page?.resourceType !== 'Bundle' || page.type !== 'searchset') { whole = false; return { resourceType: 'Bundle', type: 'searchset' }; } const next = page.link?.find((link) => link.relation === 'next'); if (next && !/page=[^&]/.test(next.url ?? '')) whole = false; const unreadable = (page.entry ?? []).some((entry) => entry.search?.mode !== 'outcome' && entry.resource?.resourceType !== 'PaymentReconciliation'); if (unreadable) whole = false; return page; }); if (truncated || !whole) throw new IncompleteLedgerError(); return resources .filter((r): r is PaymentReconciliation => r?.resourceType === 'PaymentReconciliation'); }, [get], ); // THE PATIENTS HOST NAMES NO PRACTICE, so a patient's bills there are read practice by practice: each request // names the practice explicitly, the server answers only that practice's bills for the subject it derives, and // each answer is checked here to say so itself (owned by that practice, about that patient) before it is used. // Nothing is cached across practices or viewers; `truncated` is carried to the screen, not only logged. const fetchPatientInvoicesAtPractice = useCallback( async (patientId: string, organizationId: string, request: OwnedRequest = {}): Promise => { const { resources, truncated } = await fetchAllBundlePages((pageParam) => { const params = new URLSearchParams(); params.set('subject', `Patient/${patientId}`); params.set('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Invoice?${params.toString()}`, { organizationId, ...request }); }); const invoices = resources.filter((invoice) => isBillOf(invoice, organizationId, patientId)); return { invoices, unresolved: resources.length - invoices.length, truncated }; }, [get], ); /** One bill read again through the practice that owns it; an answer that does not say so is refused. */ const fetchInvoiceAtPractice = useCallback(async (id: string, organizationId: string, request: OwnedRequest = {}): Promise => { const invoice = await get(`${API_URL}/Invoice/${id}`, { organizationId, ...request }); if (invoice?.id !== id || owningPracticeOf(invoice) !== organizationId) { throw new Error('BILL_OWNER_MISMATCH'); } return invoice; }, [get]); /** A bill's payments through its owning practice, each checked to be that practice's, with truncation kept. */ const fetchInvoicePaymentsAtPractice = useCallback( async (invoiceId: string, organizationId: string, request: OwnedRequest = {}): Promise<{ payments: PaymentReconciliation[]; truncated: boolean }> => { const { resources, truncated } = await fetchAllBundlePages((pageParam) => { const params = new URLSearchParams(); params.set('invoice', invoiceId); params.set('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/PaymentReconciliation?${params.toString()}`, { organizationId, ...request }); }); const payments = resources.filter((r): r is PaymentReconciliation => r?.resourceType === 'PaymentReconciliation'); // A payment that does not say it is this practice's makes the balance unknowable, not smaller. const foreign = payments.some((payment) => owningPracticeOf(payment) !== organizationId); return { payments: payments.filter((payment) => owningPracticeOf(payment) === organizationId), truncated: truncated || foreign }; }, [get], ); // A payment taken outside Stripe, recorded by staff. paymentKey is one per attempt so a retried // request cannot record the same payment twice. const recordPayment = useCallback( async (invoiceId: string, amountCents: number, paymentKey: string): Promise => { return post(`${PAYMENT_API_URL}/invoice-payment/${invoiceId}`, { amountCents, paymentKey }); }, [post], ); return { invoiceList, setInvoiceList, fetchInvoices, countInvoices, fetchInvoicesForPatient, fetchInvoice, createInvoice, findInvoicePatients, setInvoiceStatus, updateInvoiceDraft, setInvoicePartialPay, fetchInvoicePayments, recordPayment, fetchPatientInvoicesAtPractice, fetchInvoiceAtPractice, fetchInvoicePaymentsAtPractice, }; }; /** One practice's bills for a patient: those that say they are its and theirs, how many did not, and whether the * read stopped short of the last page. */ /** A read's owner: cancel what was sent (`signal`) and refuse to send or publish once it has moved on (`admit`). */ export type OwnedRequest = { signal?: AbortSignal; admit?: () => void }; export type PracticeInvoices = { invoices: Invoice[]; unresolved: number; truncated: boolean }; const InvoiceServiceContext = createContext | undefined>(undefined); export const InvoiceServiceProvider = ({ children }: { children: ReactNode }) => { const invoiceService = useInvoiceService(); return ( {children} ); }; export const useInvoiceServiceContext = () => { const context = useContext(InvoiceServiceContext); if (context === undefined) { throw new Error('useInvoiceServiceContext must be used within an InvoiceServiceProvider'); } return context; };