import { useCallback, useEffect, useRef, useState } from 'react'; import type { Invoice, Organization } from 'fhir/r4'; import { useInvoiceServiceContext, useOrganizationServiceContext } from '../../../services'; import { onViewerChange } from '../../../utils/writeThroughHold'; import { ownedRead } from '../../../utils/ownedRead'; /** How many practices are read at once, and how long one practice's read (token, proof and every page) may take. */ export const PRACTICE_READS_IN_FLIGHT = 3; export const PRACTICE_READ_DEADLINE_MS = 20_000; /** Online payment cannot return to the patients host yet, so a bill there says where it can be paid instead. */ export const PAY_FROM_PRACTICE_MESSAGE = 'Online payment is not available from this page yet. Pay this bill from your practice’s own patient portal, or at the practice.'; export type PatientBill = { invoice: Invoice; practiceId: string; practiceName?: string }; type Practice = { id: string; name?: string }; export type PatientBills = { /** Still asking: nothing below is a statement about the patient yet. */ loading: boolean; bills: PatientBill[]; /** Practices whose bills could not be read; their bills are not in `bills`, so the list is not complete. */ failed: Practice[]; /** Practices whose list stopped short of its last page. */ truncated: Practice[]; /** Bills a practice answered with that did not say they were its and this patient's; never shown. */ unresolved: number; /** The practices themselves could not be found: there is no list to show at all. */ discoveryFailed: boolean; /** Read again only what is unfinished: every practice when none were found, else the failed ones. */ retry: () => void; }; const practicesOf = (organizations: Organization[]): Practice[] => organizations .filter((organization) => Boolean(organization.id)) .map((organization) => ({ id: organization.id as string, name: organization.name?.trim() || undefined })); const EMPTY: Omit = { loading: true, bills: [], failed: [], truncated: [], unresolved: 0, discoveryFailed: false }; /** * A patient's bills on the patients host, where no practice is named: the practices that could hold one are found * first, then each is read through the existing practice-bound Invoice read with that practice named explicitly. * A practice that fails is named and its siblings still show; nothing partial is ever presented as complete. The * newest read owns the result: another viewer, another subject or a newer read retires the older one. */ export const usePatientBills = (patientId: string | undefined, enabled: boolean, reloadKey: number): PatientBills => { const { fetchBillingPractices } = useOrganizationServiceContext(); const { fetchPatientInvoicesAtPractice } = useInvoiceServiceContext(); const [state, setState] = useState>(EMPTY); const generation = useRef(0); // Every request this hook has out, so a replacement or unmount cancels them all at once. const inFlight = useRef(new Set()); const [retryKey, setRetryKey] = useState(0); // A new viewer, even one reading the same patient id, starts a new read rather than leaving the old one retired. const [viewer, setViewer] = useState(0); const lastPractices = useRef(); const onlyFailed = useRef(); // Another person signed in, or another subject switched to: nothing read for the last one survives. useEffect(() => onViewerChange(() => { generation.current += 1; inFlight.current.forEach((controller) => controller.abort()); lastPractices.current = undefined; onlyFailed.current = undefined; setState(EMPTY); setViewer((epoch) => epoch + 1); }), []); useEffect(() => { generation.current += 1; const mine = generation.current; const owns = () => generation.current === mine; const requests = inFlight.current; const owned = { deadlineMs: PRACTICE_READ_DEADLINE_MS, owns, track: requests }; if (!enabled || !patientId) { setState(EMPTY); return; } const retrying = onlyFailed.current; onlyFailed.current = undefined; const run = async () => { let practices = retrying ?? lastPractices.current; if (!retrying) { setState(EMPTY); try { practices = practicesOf(await ownedRead((request) => fetchBillingPractices(request), owned)); } catch (discoveryError) { console.error('Bills: the practices could not be found:', discoveryError); if (owns()) setState({ ...EMPTY, loading: false, discoveryFailed: true }); return; } if (!owns()) return; lastPractices.current = practices; } else { setState((current) => ({ ...current, loading: true })); } const queue = [...(practices ?? [])]; const read: Array<{ practice: Practice; bills: Invoice[]; unresolved: number; truncated: boolean } | { practice: Practice; failed: true }> = []; const next = async (): Promise => { while (owns() && queue.length) { const practice = queue.shift() as Practice; try { const answer = await ownedRead( (request) => fetchPatientInvoicesAtPractice(patientId, practice.id, request), owned, ); read.push({ practice, bills: answer.invoices, unresolved: answer.unresolved, truncated: answer.truncated }); } catch (readError) { console.error(`Bills: Organization/${practice.id} could not be read:`, readError); read.push({ practice, failed: true }); } } }; await Promise.all(Array.from({ length: Math.min(PRACTICE_READS_IN_FLIGHT, queue.length) }, () => next())); if (!owns()) return; const answered = read.filter((entry): entry is Extract => 'bills' in entry); const fresh = answered.flatMap(({ practice, bills }) => bills.map((invoice) => ({ invoice, practiceId: practice.id, practiceName: practice.name }))); const readIds = new Set(read.map((entry) => entry.practice.id)); setState((current) => ({ loading: false, discoveryFailed: false, // A retry keeps what the other practices already answered and replaces only what it read again. bills: [...(retrying ? current.bills.filter((bill) => !readIds.has(bill.practiceId)) : []), ...fresh], failed: [ ...(retrying ? current.failed.filter((practice) => !readIds.has(practice.id)) : []), ...read.filter((entry) => 'failed' in entry).map((entry) => entry.practice), ], truncated: [ ...(retrying ? current.truncated.filter((practice) => !readIds.has(practice.id)) : []), ...answered.filter((entry) => entry.truncated).map((entry) => entry.practice), ], unresolved: (retrying ? current.unresolved : 0) + answered.reduce((sum, entry) => sum + entry.unresolved, 0), })); }; void run(); return () => { generation.current += 1; requests.forEach((controller) => controller.abort()); }; // fetch* identities churn with their providers' caches; the read is keyed on who and what is asked. // eslint-disable-next-line react-hooks/exhaustive-deps }, [patientId, enabled, reloadKey, retryKey, viewer]); const shown = useRef(state); shown.current = state; const retry = useCallback(() => { const current = shown.current; onlyFailed.current = current.discoveryFailed || current.failed.length === 0 ? undefined : current.failed; setRetryKey((key) => key + 1); }, []); return { ...state, retry }; };