import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import type { Invoice, Reference } from "fhir/r4"; import { useAuthServiceContext, usePatientServiceContext, useInvoiceServiceContext } from "../../../services"; import { readInvoiceDueDate } from "../../../utils/invoiceTransform"; import { remainingCents } from "../../../utils/paymentModel"; import { isOrglessPatientsHost } from "../../../utils/hostedMount"; import { onViewerChange } from "../../../utils/writeThroughHold"; import { useCurrentBillingAccess } from "../../../hooks/useCurrentBillingAccess"; import { usePatientBills } from "../BillingPage/usePatientBills"; import { useInvoiceLedgers, type RowLedger } from "../BillingPage/useInvoiceLedgers"; import { PatientBillsCheckContext, type PatientBillsCheck } from "./patientBillsCheck"; /** * The patient's unpaid invoices, with what they actually owe on each. + 279. * * EXTRACTED SO THE BAND AND THE BANNER READ ONE LOAD. The summary band needs the count and the * total; the urgent notice needs to know whether any of them is past due or failed. Two hooks each * fetching would be two answers to one question - and on the one surface where being wrong costs * money. * * It FETCHES rather than reading the shared `invoiceList`, deliberately: that list is only * populated once someone opens the invoices page, so reading it alone would tell a patient who never * has that they owe nothing. Reporting zero because nobody asked is defect class. */ export type UnpaidInvoice = { id: string; /** What is still owed, after payments - never the invoiced total. Unknown when the payments could not be read. */ amountCents: number | undefined; dueDate?: string; /** / D4 - every queue row names its organisation, so the invoice carries its issuer. */ issuerName?: string; /** * THE REFERENCE, because the display is usually not there. Spencer, 2026-09-08: "Get the * org logo/names in the cards INCLUDING INVOICES." * * `issuer.display` alone is the same trap the task rows fell into - measured on the running * portal, 33 of 40 tasks carried an organization reference with NO display - so a row that reads * the display and stops is blank most of the time. The queue resolves this id against the * patient's own practices. */ issuerRef?: Reference; /** A payment attempt that came back in error, as opposed to one that was never made. */ failed: boolean; }; export type UnpaidInvoices = { invoices: UnpaidInvoice[]; /** False when a practice or a page could not be read: bills may be missing, so no count or total is exact. */ complete: boolean; }; const createdAtOrganization = (invoice: Invoice) => invoice.extension?.find( (extension) => extension.url === "https://akashichealthrecords.com/fhir/extensions/createdAtOrganization", )?.valueReference; // The practice is usually on the createdAtOrganization extension rather than `issuer`; `issuer` still wins. const unpaidFrom = (invoice: Invoice, ledger: RowLedger, practiceName?: string): UnpaidInvoice => { const createdAt = createdAtOrganization(invoice); const base = { id: invoice.id as string, dueDate: readInvoiceDueDate(invoice), issuerName: invoice.issuer?.display ?? createdAt?.display ?? practiceName, issuerRef: invoice.issuer ?? createdAt, }; // A ledger we could not read leaves the amount unknown: neither zero nor the whole invoiced total. if (ledger.read === "failed") return { ...base, amountCents: undefined, failed: false }; return { ...base, amountCents: remainingCents(invoice, ledger.payments), failed: ledger.payments.some((payment) => payment.outcome === "error"), }; }; /** * RETURNS `undefined` UNTIL THE ANSWER ARRIVES, and that is the whole point of the type. * * It used to start as `[]`, which the summary band rendered as a confident *"0 invoices unpaid"* - * measured for about two seconds on in-app return while the patient owed $245 across two invoices. **An * empty list is an ANSWER; the absence of one is not**, and only the caller can tell them apart if the * type does. */ const usePatientUnpaidInvoicesLoad = (): { unpaid: UnpaidInvoices | undefined; check: PatientBillsCheck } => { const { patientState } = usePatientServiceContext(); const { wpOrganization } = useAuthServiceContext(); const { fetchInvoicesForPatient } = useInvoiceServiceContext(); const billing = useCurrentBillingAccess(); const patientId = patientState?.id; // Only someone who may see this patient's bills asks for them; until that is known nothing is read. const readable = billing.answered && billing.canView; const patientsHost = isOrglessPatientsHost(); // The patients host names no practice, so its bills are read practice by practice, each with its owner. const hostBills = usePatientBills(patientId, patientsHost && readable, 0); // Anywhere else the current practice reads them. Another viewer, patient or practice retires an older read. const [viewer, setViewer] = useState(0); useEffect(() => onViewerChange(() => setViewer((epoch) => epoch + 1)), []); const readKey = `${viewer}|${patientId ?? ""}|${wpOrganization?.current?.id ?? ""}`; const [practiceRead, setPracticeRead] = useState<{ key: string; invoices?: Invoice[]; failed?: true }>(); useEffect(() => { setPracticeRead(undefined); if (patientsHost || !readable || !patientId) return; let cancelled = false; fetchInvoicesForPatient(patientId).then( (invoices) => { if (!cancelled) setPracticeRead({ key: readKey, invoices: invoices ?? [] }); }, () => { if (!cancelled) setPracticeRead({ key: readKey, failed: true }); }, ); return () => { cancelled = true; }; // fetchInvoicesForPatient churns with the shared invoice cache; the read is keyed on who and what is asked. // eslint-disable-next-line react-hooks/exhaustive-deps }, [readKey, patientsHost, readable]); // Issued is the unpaid state; balanced is paid and cancelled is void. Undefined is still reading or unreadable. const source = useMemo(() => { if (patientsHost) { if (hostBills.loading) return undefined; // No practice could be found: the list is unread, which is never an empty one. if (hostBills.discoveryFailed) return { invoices: [], practiceOf: undefined, practiceNames: undefined, complete: false }; const bills = hostBills.bills.filter((bill) => bill.invoice.status === "issued"); return { invoices: bills.map((bill) => bill.invoice), practiceOf: new Map(bills.map((bill) => [bill.invoice.id as string, bill.practiceId])), practiceNames: new Map(bills.map((bill) => [bill.invoice.id as string, bill.practiceName])), complete: hostBills.failed.length === 0 && hostBills.truncated.length === 0 && hostBills.unresolved === 0, }; } if (practiceRead?.key !== readKey) return undefined; if (!practiceRead.invoices) return { invoices: [], practiceOf: undefined, practiceNames: undefined, complete: false }; return { invoices: practiceRead.invoices.filter((invoice) => invoice.status === "issued"), practiceOf: undefined, practiceNames: undefined, complete: true, }; }, [patientsHost, hostBills, practiceRead, readKey]); const issued = useMemo(() => source?.invoices ?? [], [source]); const ledgers = useInvoiceLedgers(issued, issued.length > 0, 0, source?.practiceOf); const unpaid = useMemo(() => { if (!readable || !source) return undefined; // Every issued bill's payments must have answered, or failed, before an amount is stated. if (issued.some((invoice) => !ledgers.has(invoice.id as string))) return undefined; const invoices = issued .map((invoice) => unpaidFrom(invoice, ledgers.get(invoice.id as string) as RowLedger, source.practiceNames?.get(invoice.id as string))) .filter((invoice) => invoice.amountCents === undefined || invoice.amountCents > 0); return { invoices, complete: source.complete }; }, [readable, source, issued, ledgers]); // Only an answered "no" leaves bills out of what the home may claim; an unknown right is still being read. const check = useMemo((): PatientBillsCheck => { if (billing.failed) return { loading: false, failed: true }; if (billing.answered && !billing.canView) return { loading: false, failed: false }; if (!unpaid) return { loading: true, failed: false }; return { loading: false, failed: !unpaid.complete }; }, [billing.failed, billing.answered, billing.canView, unpaid]); return { unpaid, check }; }; /** * - EXTRACTING A HOOK DID NOT SHARE THE LOAD, AND THE HEADER ABOVE ALREADY SAID IT SHOULD. * * *"EXTRACTED SO THE BAND AND THE BANNER READ ONE LOAD... two hooks each fetching would be two answers to * one question - and on the one surface where being wrong costs money."* **That was the intent and the * code could not deliver it.** A plain hook gives every caller its own `useState` and its own `useEffect`, * so the three callers - the summary band, the queue and the urgent notice - each fetched independently. * Measured in a browser 2026-08-22: **four `Invoice?subject=...` requests in one page load.** * * **It is a CORRECTNESS bug wearing an efficiency costume, which is why it is fixed rather than * tolerated.** Three independent loads can disagree - a payment landing between them shows the patient a * band saying $245 beside a banner saying $120 - and the header names that exact hazard. * * IT FAILS SOFT WITHOUT A PROVIDER, DELIBERATELY. Unprovided it returns `undefined`, which this * module already defines as *"the answer has not arrived"* - never a throw, because a context consumer * without its provider takes a whole workspace down *. * The cost of failing soft is that a wiring mistake reads as a permanent loading state instead of an * error, so **a guard asserts the provider actually wraps the home page**. Soft runtime, hard test. */ const PatientUnpaidInvoicesContext = createContext(undefined); export const PatientUnpaidInvoicesProvider = ({ children }: { children: ReactNode }) => { const { unpaid, check } = usePatientUnpaidInvoicesLoad(); return ( {children} ); }; /** One load, one answer, however many surfaces ask. Never re-add a fetch to a caller. */ export const usePatientUnpaidInvoices = (): UnpaidInvoices | undefined => useContext(PatientUnpaidInvoicesContext);