// src/pages/DashboardPage/BillingPage/PatientInvoiceList.tsx // Phase 2 invoicing, Task 9: the patient-scoped invoice list shared by the receptionist workspace's // Invoices tab and the patient portal's "My Invoices" page. Fetches via // InvoiceService.fetchInvoicesForPatient for the patient currently in `patientState` (same context // CoverageListPage already reads - the receptionist dialog's parent sets it before opening, and it // auto-populates to the logged-in patient's own record for the portal case: see PatientService.tsx). // Stays live via useFhirInvoiceBroadcast(patientId) (Task 7), which writes into the shared // InvoiceService `invoiceList` cache scoped to this patient; that cache is folded back into the // locally-rendered rows below. // // Two usage modes, mirroring CoverageListPage's onOpenCoverage/onAddCoverage: // - onOpenInvoice provided (the receptionist dialog): row click hands off the id, and the caller owns // the list<->detail swap itself (matching the Insurance tab's selectedCoverageId pattern, so the // dialog's shared header Back button can pop it). // - onOpenInvoice omitted (the patient portal): self-contained - row click opens InvoiceDetail in its // own dialog (same shape as Task 8's InvoiceList.tsx). // "New Invoice" (canManage only) is always self-contained here - InvoiceEditor is already an overlay // dialog with its own Save/Issue actions, so there's no Back-button coordination needed either way. import { useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import type { Invoice } from 'fhir/r4'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogClose, } from '../../../components/ui/dialog'; import { EmptyState } from '../../../components/ui/empty-state'; import { Alert, AlertDescription, AlertTitle } from '../../../components/ui/alert'; import { ReceiptText, X } from 'lucide-react'; import { useInvoiceServiceContext, usePatientServiceContext } from '../../../services'; import { useFhirInvoiceBroadcast } from '../../../hooks'; import { invoiceTotalCents } from '../../../utils/invoiceTransform'; import { formatMoney } from '../../../utils/money'; import { describeFetchFailure, isRetryableFailure } from '../../../utils/fetchFailureMessage'; import { formatDate } from '../../../utils/dateUtils'; import InvoiceDetail, { invoiceNumberOf } from './InvoiceDetail'; import { invoiceRowLabel, invoiceState, newestShownFirst, pastDue, pastDueWithRemaining } from './invoiceDisplay'; import { isPartiallyPaid, remainingCents } from '../../../utils/paymentModel'; import { useInvoiceLedgers } from './useInvoiceLedgers'; import { InvoiceStatusMark, invoiceStateLabel } from './InvoiceStatusMark'; import InvoiceEditor from './InvoiceEditor'; import { usePaymentReturn } from './usePaymentReturn'; import { useCurrentBillingAccess } from '../../../hooks/useCurrentBillingAccess.tsx'; import { createdAtOrganization } from '../../../utils/createdAtOrganization'; import { usePatientBills, type PatientBills } from './usePatientBills'; import { isOrglessPatientsHost } from '../../../utils/hostedMount'; import './PatientInvoiceList.css'; export type PatientInvoiceListProps = { canManage: boolean; onOpenInvoice?: (invoiceId: string) => void; // Patient-portal ONLY (App.tsx `invoices` route). Enables the "Pay now" button on issued invoices // (threaded through to InvoiceDetail) and the return-from-redirect poll below. The receptionist/staff // mount omits it, so neither the button nor the poll ever appears in the staff view (Slice C2). patientPay?: boolean; }; /** * What the practice-by-practice read could not finish, said beside the bills it did find: a practice whose bills * could not be read (named, with Try again for it alone), a list that stopped short, and bills that did not say they * were the practice's own. None of these is ever shown as a complete list. */ const PracticeReadNotes = ({ bills }: { bills: ReturnType }) => { const named = (practices: { name?: string }[]) => practices.map((practice) => practice.name ?? 'a practice').join(', '); return ( <> {bills.failed.length > 0 && ( Some of your bills could not be loaded.

Bills from {named(bills.failed)} are not shown yet.

)} {bills.truncated.length > 0 && (

Not every bill from {named(bills.truncated)} could be listed here. Contact the practice for the rest.

)} {bills.unresolved > 0 && (

Some bills could not be matched to the practice that sent them and are not shown. Contact your practice about them.

)} ); }; // Off the patients host there is no practice-by-practice read: the list reads as it always has. const NO_PRACTICE_BILLS: PatientBills = { loading: false, bills: [], failed: [], truncated: [], unresolved: 0, discoveryFailed: false, retry: () => undefined }; /** Only a read with nothing failed, cut short or unresolved may say there are no bills. */ const answeredInFull = (bills: PatientBills) => bills.failed.length === 0 && bills.truncated.length === 0 && bills.unresolved === 0 && !bills.discoveryFailed; const InvoiceListBody = ({ canManage, onOpenInvoice, patientPay = false, practiceBills: hostBills }: PatientInvoiceListProps & { practiceBills?: PatientBills }) => { const { patientState } = usePatientServiceContext(); const patientId = patientState?.id; // (related-person financial disclosure) - PORTAL MOUNT ONLY. `patientPay` marks the // patient-portal mount, which is the one a related person reaches (acting for the patient whose // record is displayed); the receptionist/staff mount passes it false and is governed by billing:view, // untouched by this card. For a patient viewing their OWN invoices the hook grants immediately, so // the self path is unchanged. // // UX ONLY. Invoice/get.ts enforces the same grant server-side and returns an empty bundle to an // ungranted related person, so this gate is about not showing someone a page that would come back // empty and unexplained - it is not what keeps the data private. const billingAccess = useCurrentBillingAccess(); const { canView: canViewBilling, canPay: canPayBilling } = billingAccess; // Not yet known is neither the list nor the denial: nothing financial is read or shown until it has answered. const billingUnknown = patientPay && !billingAccess.answered; const billingGated = patientPay && billingAccess.answered && !canViewBilling; const billingReadable = !patientPay || canViewBilling; const { fetchInvoicesForPatient, invoiceList, setInvoiceList } = useInvoiceServiceContext(); const [invoices, setInvoices] = useState([]); const [loading, setLoading] = useState(false); /** * HAS THE SERVER BEEN ASKED YET? - and it is NOT the same question as `loading`. * * `loading` starts `false` and the fetch effect raises it only after the first paint - and returns * early while `patientId` is still resolving from the app-level patient context. So this screen * rendered *"No invoices yet."* to a patient whose invoices had never been requested. * A screen that makes a promise about money must not assert a zero it has not measured; * is the same defect, and the empty-state primitive's own note says a prettier false statement is * worse than an ugly one. */ const [everAsked, setEverAsked] = useState(false); const [loadError, setLoadError] = useState(); // A refusal is told as a refusal; anything else offers Try again. const [loadRefused, setLoadRefused] = useState(false); const [editorOpen, setEditorOpen] = useState(false); const [selectedInvoice, setSelectedInvoice] = useState(null); // Bumped by the payment-return poll to force a re-fetch once a payment is confirmed (the live // broadcast may also flip the row; both converging is fine). const [reloadNonce, setReloadNonce] = useState(0); const [searchParams, setSearchParams] = useSearchParams(); // Scope live invoice.created/updated/deleted broadcasts to this patient (Task 7). useFhirInvoiceBroadcast(patientId); // THE PATIENTS HOST NAMES NO PRACTICE: the patient's bills there are read practice by practice, each bill with // the practice that owns it (usePatientBills). Anywhere else this list reads as it always has. const onPatientsHost = Boolean(hostBills); const practiceBills = hostBills ?? NO_PRACTICE_BILLS; const shownInvoices = useMemo( () => (onPatientsHost ? practiceBills.bills.map((entry) => entry.invoice) : invoices), [onPatientsHost, practiceBills.bills, invoices], ); const practiceOf = useMemo( () => new Map(practiceBills.bills.map((entry) => [entry.invoice.id as string, entry.practiceId])), [practiceBills.bills], ); // Part paid comes from each issued bill's ledger, never a stored balance (partial-payment design item 12). const ledgers = useInvoiceLedgers(shownInvoices, billingReadable, reloadNonce, onPatientsHost ? practiceOf : undefined); // Portal-only: on return from Stripe's hosted page, confirm the payment and refresh the list. Payment is not // offered from the patients host yet, so nothing returns there. usePaymentReturn(patientPay && !onPatientsHost, () => setReloadNonce((n) => n + 1)); // Initial fetch, then merge into the shared cache so it agrees with what the broadcast hook above // will keep updating going forward (fetchInvoicesForPatient/setInvoiceList are deliberately left out // of the dependency array - InvoiceService's identity churns whenever its shared invoiceList cache // changes - this effect keys purely on patientId, matching InvoiceDetail's own precedent). useEffect(() => { if (!patientId || onPatientsHost || !billingReadable) return; let active = true; setLoading(true); setLoadError(undefined); fetchInvoicesForPatient(patientId) .then((fetched) => { if (!active) return; setInvoices(fetched); setInvoiceList((prev) => { const fetchedIds = new Set(fetched.map((invoice) => invoice.id)); return [...prev.filter((invoice) => !fetchedIds.has(invoice.id)), ...fetched]; }); }) .catch((fetchError) => { console.error('Failed to load invoices:', fetchError); // A PERMANENT DENIAL MUST NOT ASK FOR A RETRY. Measured 2026-09-08 as Receptionist // One, opening Patient One's Invoices tab: GET /R4/Invoice answered 403 and this // read "Failed to load invoices. Please try again." - an instruction to repeat an // action that cannot ever succeed, which moves the blame to the reader and invites // them to hammer a refused endpoint. `describeFetchFailure` decides retryable-or-not // from the RESPONSE rather than from the words. if (active) { setLoadError(describeFetchFailure(fetchError, 'Failed to load invoices')); setLoadRefused(!isRetryableFailure(fetchError)); } }) .finally(() => { if (active) { setLoading(false); setEverAsked(true); } }); return () => { active = false; }; // reloadNonce re-runs this fetch after a confirmed payment; patientId is the primary key. // eslint-disable-next-line react-hooks/exhaustive-deps }, [patientId, reloadNonce, billingReadable]); // Fold live broadcast updates for this patient (already scoped by useFhirInvoiceBroadcast above) into // the locally-rendered list. Invoices aren't archived/deleted in normal use (see InvoiceDetail's own // comment), so only create/update are folded in here. useEffect(() => { // On the patients host the list is each practice's own answer; a broadcast is not laid over it. if (!patientId || onPatientsHost) return; setInvoices((current) => { let next = current; for (const incoming of invoiceList) { if (incoming.subject?.reference !== `Patient/${patientId}`) continue; const index = next.findIndex((invoice) => invoice.id === incoming.id); if (index === -1) { next = [...next, incoming]; } else if (next[index] !== incoming) { next = next.map((invoice, i) => (i === index ? incoming : invoice)); } } return next; }); }, [invoiceList, patientId, onPatientsHost]); // A bill opened on the patients host belongs to the read that listed it: once that read is retired (another viewer // or subject) or no longer names the bill's owner, the opening closes and nothing of it is read or shown again. useEffect(() => { if (onPatientsHost && selectedInvoice && !practiceOf.has(selectedInvoice.id as string)) setSelectedInvoice(null); }, [onPatientsHost, selectedInvoice, practiceOf]); // Deep-link: when a "Payments due" task (or any link) lands here with ?invoice=, auto-open that // invoice's detail once it is present in the fetched list, then strip the param so closing the dialog // does not immediately re-open it. Self-contained (patient-portal) mode only. useEffect(() => { if (onOpenInvoice) return; const wanted = searchParams.get('invoice'); if (!wanted) return; const match = shownInvoices.find((invoice) => invoice.id === wanted); if (!match) return; setSelectedInvoice(match); const next = new URLSearchParams(searchParams); next.delete('invoice'); setSearchParams(next, { replace: true }); }, [searchParams, shownInvoices, onOpenInvoice, setSearchParams]); // The open bill's state for its header, read the way its row reads it: from the ledger when that // read answered, else from the stored status and due date. const selectedLedger = selectedInvoice?.id ? ledgers.get(selectedInvoice.id) : undefined; const headerOverdue = selectedInvoice ? (selectedLedger?.read === 'ready' ? pastDueWithRemaining(selectedInvoice, remainingCents(selectedInvoice, selectedLedger.payments)) : pastDue(selectedInvoice)) : false; const handleRowClick = (invoice: Invoice) => { if (onOpenInvoice) { if (invoice.id) onOpenInvoice(invoice.id); return; } setSelectedInvoice(invoice); }; //: a related person without financial disclosure sees an explanation, not an empty list - // "no invoices" would be a lie, and silence would read as a bug. Deliberately does NOT name the // patient's financial state, only the permission. if (billingUnknown) { return billingAccess.failed ? ( We couldn’t check your access to these bills. ) : (

Checking your access to these bills…

); } if (billingGated) { return (

You do not have access to this person's invoices and payments. They can grant it from their Care access settings.

); } return (
{canManage && (
)} {onPatientsHost && !practiceBills.loading && !practiceBills.discoveryFailed && ( )} {onPatientsHost && practiceBills.discoveryFailed ? ( // Not knowing where the bills are is not "no bills". We couldn’t find your bills.

The practices that bill you could not be looked up just now.

) : (onPatientsHost ? practiceBills.loading : (loading || !everAsked)) ? ( // The deck's "not asked yet" frame: a quiet note, never the empty state.

Loading invoices…

) : !onPatientsHost && loadError ? ( loadRefused ? (

{loadError}

) : ( // A failed read is never "No bills yet"; it says so and can be tried again. // The shared error notice; the retry sits in the description column with the sentence. We couldn’t load your bills.

Your bills are temporarily unavailable.

) ) : shownInvoices.length === 0 && (!onPatientsHost || answeredInFull(practiceBills)) ? ( // The shared empty state. NO action prop in either mount: staff already // have "New Invoice" in the header above, and a patient cannot create a bill at all - so // the honest next step for them is not an action but WHAT PRODUCES CONTENT HERE. // The patient's is the deck's card: the shared card holding the words, no icon. ) : ( // Separate lifted cards, three lines at every width, as the deck's phone, tablet and // laptop frames all draw them (the 09-01 sweep made the three-line row the only row).
{newestShownFirst(shownInvoices).map((invoice) => { // An unread or failed ledger falls back to the stored status alone. const ledger = invoice.id ? ledgers.get(invoice.id) : undefined; const payments = ledger?.read === 'ready' ? ledger.payments : undefined; const remaining = payments ? remainingCents(invoice, payments) : undefined; const overdue = remaining === undefined ? pastDue(invoice) : pastDueWithRemaining(invoice, remaining); const partPaid = payments !== undefined && isPartiallyPaid(invoice, payments); const state = invoiceState(invoice, overdue, partPaid); // `createdAtOrganization` names the practice; nothing populates `issuer`, which is // kept only as a fallback for an invoice that does carry one. const practice = createdAtOrganization(invoice).name ?? invoice.issuer?.display?.trim(); // Amber means owed and late or part-settled (deck: past due and part paid only). const owed = state === 'past-due' || state === 'part-paid'; return ( ); })}
)} {canManage && patientId && ( { setEditorOpen(false); setInvoices((current) => { const index = current.findIndex((invoice) => invoice.id === saved.id); return index === -1 ? [saved, ...current] : current.map((invoice, i) => (i === index ? saved : invoice)); }); if (onOpenInvoice && saved.id) onOpenInvoice(saved.id); }} /> )} {!onOpenInvoice && ( { if (!open) setSelectedInvoice(null); }} > {/* The deck's bill header: the number, its state beside it, the close at the far right, and a rule under all three. */} Invoice {selectedInvoice ? (invoiceNumberOf(selectedInvoice) ?? selectedInvoice.id) : ''} {selectedInvoice && ( )} Invoice details {selectedInvoice && (
{ setSelectedInvoice(updated); setInvoices((current) => current.map((invoice) => (invoice.id === updated.id ? updated : invoice))); }} />
)}
)}
); }; // THE PATIENTS HOST NAMES NO PRACTICE: there the patient's bills are read practice by practice (usePatientBills), // each with the practice that owns it, and the list shows that read. Only the patient-portal mount asks. const PatientsHostInvoiceList = (props: PatientInvoiceListProps) => { const { patientState } = usePatientServiceContext(); const { canView } = useCurrentBillingAccess(); const practiceBills = usePatientBills(patientState?.id, canView, 0); return ; }; const PatientInvoiceList = (props: PatientInvoiceListProps) => ( props.patientPay && isOrglessPatientsHost() ? : ); export default PatientInvoiceList;