// src/pages/DashboardPage/BillingPage/InvoiceDetail.tsx // Phase 2 invoicing, Task 6: a read view of one invoice - invoice number, patient reference, status // badge, line items, and the total - plus the status-only actions gated by `canManage` AND the invoice's // current status: // draft -> Edit (reopens the Task 5 InvoiceEditor), Issue, Void // issued -> Mark as paid, Void // balanced / cancelled / entered-in-error -> read-only, no actions // Callers pass either `invoiceId` (this component fetches it via InvoiceService.fetchInvoice) or an // already-loaded `invoice` (e.g. a row a list already has in hand) - the prop resource always wins so a // realtime row replacement (Task 7's broadcast hook) is reflected immediately. Not yet wired into a page: // Task 8 (BillingPage's list) and Task 9 (receptionist tab + patient portal) mount this. import { useCallback, useEffect, useRef, useState } from 'react'; import { OverlayActions } from '../../../components/ui/overlay-actions'; import type { Invoice, PaymentReconciliation } from 'fhir/r4'; import { Checkbox } from '../../../components/ui/checkbox'; import { Input } from '../../../components/ui/input'; import { Printer } from 'lucide-react'; import { useInvoiceServiceContext, usePaymentServiceContext } from '../../../services'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOptionalBillingContext } from '../../../services/BillingContext'; import { buildCheckoutUrls, parsePartialPayAmount } from '../../../utils/paymentFlow'; import { buildCheckoutRequestBody, readCheckoutAttempt, writeCheckoutAttempt, type CheckoutAttempt, type CheckoutOutcome, type CheckoutRequestBody, type CheckoutIntent, } from '../../../utils/invoiceCheckoutAttempt'; import { getPaymentGatewayConfig } from '../../../utils/paymentGateway'; import { environment } from '../../../environments/environment'; import { Alert, AlertDescription, AlertTitle } from '../../../components/ui/alert'; import { SANDBOX_DISABLED_TITLE } from '../../../components/SandboxMode/SandboxDisabled'; import { readInvoiceAllowPartialPay, readInvoiceLines, invoiceSubtotalCents, invoiceTaxCents, invoiceTotalCents, lineAmountCents, } from '../../../utils/invoiceTransform'; import { paidCents, remainingCents, isPartiallyPaid, paymentHistory, isIncompleteLedger } from '../../../utils/paymentModel'; import { formatMoney } from '../../../utils/money'; import { formatDate } from '../../../utils/dateUtils'; import InvoiceEditor from './InvoiceEditor'; import RecordPaymentModal from './RecordPaymentModal'; import { readPendingStaffPayment, writePendingStaffPayment } from './pendingStaffPayment'; import { paymentRefusal } from './staffPaymentRefusal'; import { HttpError } from '../../../services/HttpService.tsx'; import { useInvoicePayLinkService } from '../../../services/InvoicePayLinkService.tsx'; import { PAY_LINK_ENDED, PAY_LINK_END_FAILED, payLinkSentMessage } from './payLinkCopy'; import type { RecordedPayment } from '../../../services/InvoiceService.tsx'; import InvoiceStatusModal, { type InvoiceStatusAction, type InvoiceConfirmedStatus } from './InvoiceStatusModal'; import PrintableInvoice from './PrintableInvoice'; // Invoice-display helpers now live in invoiceDisplay.ts (React-free, shared with the print model); // re-exported here so existing `from './InvoiceDetail'` importers (InvoiceList, PatientInvoiceList) are // unchanged. export { invoiceNumberOf, STATUS_LABEL, statusLabel, pastDue, invoicePatientLabel } from './invoiceDisplay'; import { invoiceNumberOf, pastDueWithRemaining, invoiceState } from './invoiceDisplay'; import { InvoiceStatusMark } from './InvoiceStatusMark'; import { PAY_FROM_PRACTICE_MESSAGE, PRACTICE_READ_DEADLINE_MS } from './usePatientBills'; import { ownedRead, type ReadRequest } from '../../../utils/ownedRead'; import { viewerEpoch, writeThroughViewer } from '../../../utils/writeThroughHold'; import { useSaveScope, type SaveOperation } from '../../../utils/saveOperation'; export type InvoiceDetailProps = { // Provide exactly one: an id to fetch, or an already-loaded resource. Both absent renders "not found". invoiceId?: string; invoice?: Invoice; onChanged: (invoice: Invoice) => void; canManage: boolean; // Whether to render the "Invoice {number}" heading in this body. Default false: the two dialog mounts // (Billing InvoiceList, patient-portal PatientInvoiceList) already show the number in their DialogTitle, // so an internal heading just duplicates it. The receptionist workspace shows the PATIENT name in its // shared header instead of the number, so it passes true to keep the number visible here. showInvoiceNumber?: boolean; // Patient-portal ONLY: renders the "Pay now" button on an issued invoice (Slice B). Defaults false so // the receptionist/staff mount never shows it - the staff "Take payment" door is Slice C2, deferred. patientPay?: boolean; /** The patient's own words for the status, as the list shows them ("Due", "Cancelled"). */ patientView?: boolean; /** The caller's dialog header already shows the status beside the number, so the body does not. */ statusInHeader?: boolean; /** * The patients host, which names no practice: the practice that owns this bill. Every read of the bill and its * payments names it, and none is made without it. Online payment is not offered from that host at all yet. */ practiceId?: string; /** Opened on the patients host: never an implicit-practice read, and never Pay, whether or not an owner is known. */ patientsHost?: boolean; }; type StatusModalState = { action: InvoiceStatusAction } | null; const InvoiceDetail = ({ invoiceId, invoice: invoiceProp, onChanged, canManage, showInvoiceNumber = false, patientPay = false, patientView = false, statusInHeader = false, practiceId, patientsHost = false }: InvoiceDetailProps) => { const { fetchInvoice, setInvoiceStatus, setInvoicePartialPay, fetchInvoicePayments, recordPayment, fetchInvoiceAtPractice, fetchInvoicePaymentsAtPractice } = useInvoiceServiceContext(); // On the patients host every read goes through the bill's owning practice, bounded and cancelled with the effect // that started it; with no owner known there is nothing to read. A ledger that stopped short is not a balance. const hostRead = (work: (request: ReadRequest) => Promise, track: Set) => { if (!practiceId) return Promise.reject(new Error('BILL_OWNER_UNKNOWN')); return ownedRead(work, { deadlineMs: PRACTICE_READ_DEADLINE_MS, track }); }; const readInvoice = (id: string, fresh = false, track = new Set()) => (patientsHost ? hostRead((request) => fetchInvoiceAtPractice(id, practiceId as string, request), track) : fetchInvoice(id, fresh)); const readPayments = async (id: string, track: Set) => { if (!patientsHost) return fetchInvoicePayments(id); const ledger = await hostRead((request) => fetchInvoicePaymentsAtPractice(id, practiceId as string, request), track); if (ledger.truncated) throw new Error('LEDGER_INCOMPLETE'); return ledger.payments; }; const { createCheckout, getPaymentSessionStatus } = usePaymentServiceContext(); const { currentOrganization } = useAuthServiceContext(); const orgId = currentOrganization?.id ?? ''; const billing = useOptionalBillingContext(); const [invoice, setInvoice] = useState(invoiceProp); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(); // Task 8: the invoice's payment ledger, fetched separately from the invoice resource itself (Task 7's // GET /R4/PaymentReconciliation?invoice=). Powers the paid/remaining/partially-paid summary and // history below - see the effect further down. const [payments, setPayments] = useState([]); // What has been paid is unknown until the ledger read answers, so nothing is owed or offered before then. const [paymentsRead, setPaymentsRead] = useState<'loading' | 'ready' | 'failed' | 'incomplete'>('loading'); // Which invoice the ledger above was read for, so the printed bill never carries another bill's payments. const [paymentsFor, setPaymentsFor] = useState(); const [paymentsAttempt, setPaymentsAttempt] = useState(0); const [editorOpen, setEditorOpen] = useState(false); const [statusModal, setStatusModal] = useState(null); const [actionSubmitting, setActionSubmitting] = useState(false); const [actionError, setActionError] = useState(); const { sendPayLink, endPayLinks } = useInvoicePayLinkService(); const [payLinkNotice, setPayLinkNotice] = useState<{ ok: boolean; text: string } | undefined>(); const [payLinkBusy, setPayLinkBusy] = useState(false); const [recordOpen, setRecordOpen] = useState(false); const [recordSubmitting, setRecordSubmitting] = useState(false); const [recordError, setRecordError] = useState(); // Set while a pending payment's outcome is unknown: its retry must send the same amount under the same key. const [lockedCents, setLockedCents] = useState(); // Patient-portal "Pay now" state (Slice B). paySubmitting spans the checkout call up to the redirect; // payAtDesk is the soft no_payment_gateway fallback; payError is any other checkout failure (never a wall). const [paySubmitting, setPaySubmitting] = useState(false); // Paying online was refused: said once, and not offered again for the same viewer, practice and invoice. const [refusedFor, setRefusedFor] = useState(); const [payAtDesk, setPayAtDesk] = useState(false); const [payError, setPayError] = useState(); // The original attempt's outcome is still unknown (202/503, a lost response, a network failure): // offer "Check again" on the SAME operation id, never a fresh Pay. const [payPending, setPayPending] = useState(false); // The server's own remaining balance differs from what was shown when Pay was pressed; a confirmed // retry (if the patient chooses to continue) starts a brand-new operation id. const [payChanged, setPayChanged] = useState<{ remainingCents: number; currency: string; allowPartial: boolean } | undefined>(); // An earlier attempt is still open at the provider while another amount was pressed: offered as itself. const [payOpen, setPayOpen] = useState<{ url: string; amountCents: number } | undefined>(); // Who is paying what, where. Every change of opening - viewer, practice or invoice - and unmounting retires // the checkouts started before it for good, so returning to an earlier opening does not revive them; the // viewer's epoch is also read live, for a sign-in change that has not re-rendered this page yet. const scope = `${writeThroughViewer() ?? ''}|${orgId}|${invoiceId ?? invoiceProp?.id ?? ''}`; const opening = useRef({ scope, generation: 0 }); if (opening.current.scope !== scope) opening.current = { scope, generation: opening.current.generation + 1 }; const payRefused = refusedFor === scope; // Each press is one operation of this opening: its request is admitted again after the token and proof waits. const payScope = useSaveScope(scope); useEffect(() => { setPaySubmitting(false); setPayAtDesk(false); setPayError(undefined); setPayPending(false); setPayChanged(undefined); setPayOpen(undefined); }, [scope]); useEffect(() => () => { opening.current = { scope: '', generation: opening.current.generation + 1 }; }, []); // The "Payment amount:" text as typed; null until edited, so it shows the remaining balance. // Reset per invoice so a reused dialog never carries a stale amount forward. const [partialAmountText, setPartialAmountText] = useState(null); useEffect(() => { setPartialAmountText(null); }, [invoice?.id]); // Retroactive allow-partial-pay toggle (Task 6) - billing:manage staff flipping the policy flag on an // ALREADY-ISSUED (or otherwise non-draft) invoice, via the narrow PUT /R4/invoice-partial-pay/{id} // sibling endpoint rather than the status/edit flows above. const [partialPaySubmitting, setPartialPaySubmitting] = useState(false); const [partialPayError, setPartialPayError] = useState(); // A prop resource always wins over fetching - it's the freshest copy the caller has (e.g. right after // this same component's own onChanged, or a live broadcast replacing the row). Only fetch when the // caller instead hands us just an id. fetchInvoice/setInvoiceStatus are deliberately left out of the // dependency array (InvoiceService's identity churns whenever its shared invoiceList cache changes) - // this effect keys purely on invoice identity, matching InvoiceEditor's own reset-effect precedent. useEffect(() => { if (invoiceProp) { setInvoice(invoiceProp); setLoadError(undefined); return; } if (!invoiceId) return; let active = true; const track = new Set(); setLoading(true); setLoadError(undefined); readInvoice(invoiceId, false, track) .then((fetched) => { if (active) setInvoice(fetched); }) .catch((fetchError) => { console.error('Failed to load invoice:', fetchError); if (active) setLoadError('Could not load the invoice. Try again.'); }) .finally(() => { if (active) setLoading(false); }); return () => { active = false; track.forEach((controller) => controller.abort()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [invoiceProp, invoiceId, practiceId, patientsHost]); // Fetch the payment ledger whenever we have an invoice to show one for - on mount, when the caller // swaps in a fresh `invoice` prop (e.g. a live broadcast replacing the row one level up), and after any // local status-change action below re-sets `invoice` via applyUpdate. Guarded on `invoice?.id`: a // not-yet-loaded (fetch-by-id) invoice has nothing to look up yet. A failed fetch leaves what was paid // unknown: the rest of the detail still renders, but no remaining amount is stated or offered. useEffect(() => { if (!invoice?.id) { setPayments([]); setPaymentsRead('ready'); return; } let active = true; const track = new Set(); setPaymentsRead('loading'); readPayments(invoice.id, track) .then((recs) => { if (!active) return; setPayments(recs); setPaymentsRead('ready'); setPaymentsFor(invoice.id); }) .catch((paymentsError) => { console.error('Failed to load invoice payments:', paymentsError); if (!active) return; setPayments([]); setPaymentsRead(isIncompleteLedger(paymentsError) ? 'incomplete' : 'failed'); setPaymentsFor(invoice.id); }); return () => { active = false; track.forEach((controller) => controller.abort()); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [invoice, paymentsAttempt, practiceId, patientsHost]); const applyUpdate = useCallback((updated: Invoice) => { setInvoice(updated); onChanged(updated); }, [onChanged]); // Issue is a direct status-only PUT with no confirm - only Mark-as-paid and Void confirm first (per // Task 6's brief), matching the Task 5 editor's own "Issue" button. const handleIssue = async () => { if (!invoice) return; setActionSubmitting(true); setActionError(undefined); try { applyUpdate(await setInvoiceStatus(invoice, 'issued')); } catch (issueError) { console.error('Failed to issue invoice:', issueError); setActionError('Failed to issue the invoice. Please try again.'); } finally { setActionSubmitting(false); } }; // Void is a status write. Paid is not: Mark as Paid records the remaining balance as a payment. const handleConfirmStatus = async (status: InvoiceConfirmedStatus) => { if (!invoice) return; if (status === 'balanced') { await handleMarkPaid(); return; } setActionSubmitting(true); setActionError(undefined); try { applyUpdate(await setInvoiceStatus(invoice, status)); setStatusModal(null); } catch (statusError) { console.error('Failed to update invoice status:', statusError); setActionError('Failed to update the invoice. Please try again.'); } finally { setActionSubmitting(false); } }; const clearPending = (id: string) => { writePendingStaffPayment(orgId, id, null); setLockedCents(undefined); }; const openRecordPayment = () => { const pending = invoice?.id ? readPendingStaffPayment(orgId, invoice.id) : null; setLockedCents(pending?.cents); setRecordError(pending ? `A payment of ${formatMoney(pending.cents)} was not confirmed. Record it again to confirm it; it will not be recorded twice.` : undefined); setRecordOpen(true); }; // An unconfirmed payment stays pending after close; re-reading the ledger shows it if it did land. const closeRecordPayment = () => { if (invoice?.id && readPendingStaffPayment(orgId, invoice.id)) setPaymentsAttempt((n) => n + 1); setRecordOpen(false); }; const refreshInvoice = async (id: string) => { setPaymentsAttempt((n) => n + 1); try { applyUpdate(await readInvoice(id, true)); } catch (readFailure) { console.error('Failed to re-read invoice:', readFailure); } }; // Record payment and Mark as Paid share one path: one key per amount, kept until the server confirms it. // Only a confirmed record or an explicit refusal clears the key; every other answer keeps it for a replay. const recordStaffPayment = async (id: string, amountCents: number): Promise<'recorded' | 'refused' | 'uncertain'> => { const attempt = readPendingStaffPayment(orgId, id) ?? { key: crypto.randomUUID(), cents: amountCents }; writePendingStaffPayment(orgId, id, attempt); let result: RecordedPayment | undefined; try { result = await recordPayment(id, attempt.cents, attempt.key); } catch (recordFailure) { console.error('Failed to record payment:', recordFailure); const refusal = recordFailure instanceof HttpError ? paymentRefusal(recordFailure.body) : undefined; if (refusal) { clearPending(id); setActionError(refusal); await refreshInvoice(id); return 'refused'; } } if (result?.outcome !== 'confirmed') { setLockedCents(attempt.cents); return 'uncertain'; } // Confirmed: a failed re-read afterwards must not look like an unconfirmed payment. clearPending(id); if (!result.paid && result.invoiceStatus && result.invoiceStatus !== 'issued') { setActionError('The payment was recorded. This invoice is no longer open, so it was not marked paid.'); } await refreshInvoice(id); return 'recorded'; }; // Replays an unconfirmed payment exactly as it was sent, whatever the invoice shows now. const confirmPendingPayment = async () => { if (!invoice?.id) return; const pending = readPendingStaffPayment(orgId, invoice.id); if (!pending) return; setActionSubmitting(true); setActionError(undefined); const outcome = await recordStaffPayment(invoice.id, pending.cents); setActionSubmitting(false); if (outcome === 'uncertain') { setActionError('We still could not confirm that payment. Try again; it will not be recorded twice.'); } }; const handleRecordPayment = async (amountCents: number) => { if (!invoice?.id) return; setRecordSubmitting(true); setRecordError(undefined); const outcome = await recordStaffPayment(invoice.id, amountCents); if (outcome === 'uncertain') { setRecordError('We could not confirm the payment was recorded. Try again to confirm it; the amount stays the same until then.'); } else { setRecordOpen(false); } setRecordSubmitting(false); }; // An unconfirmed payment is settled first, through Record payment, so its amount is never replaced. const handleMarkPaid = async () => { if (!invoice?.id) return; const id = invoice.id; if (readPendingStaffPayment(orgId, id)) { setStatusModal(null); openRecordPayment(); return; } setActionSubmitting(true); setActionError(undefined); const outcome = await recordStaffPayment(id, remainingCents(invoice, payments)); setActionSubmitting(false); setStatusModal(null); if (outcome === 'uncertain') openRecordPayment(); }; // Patient-portal "Pay now": open a hosted checkout session and redirect the browser to it. successUrl / // cancelUrl are same-origin portal URLs carrying the current HashRouter route (so the return lands back // on the invoices page) and NO session_id - the server appends session_id={CHECKOUT_SESSION_ID} and // validates the host. A no_payment_gateway result is a soft pay-at-desk message, never an error wall. // `amountCents` is optional (Task 9, partial pay): omitted charges the full REMAINING balance (never // the raw invoice total - critical after an earlier partial payment); when provided it's the patient's // chosen partial amount, already client-validated by parsePartialPayAmount at the call site - the // server independently re-validates it regardless (client validation is UX only). // The viewer+practice+invoice key the pending-attempt store is scoped by (opening.current.scope // already carries exactly this, generation aside). const checkoutScope = () => ({ viewer: writeThroughViewer() ?? '', practiceId: orgId, invoiceId: invoice?.id ?? '' }); // A stored attempt with no stored request: read by its session when it has one, never created or replayed. Paid and // settled, or ended unpaid, disposes of it; anything else leaves it unresolved, said so, with Check again. const checkUnreplayable = async ( attempt: CheckoutAttempt, viewer: string, practiceId: string, id: string, operation: SaveOperation, current: () => boolean, ) => { const unresolved = () => { setPayPending(true); setPayError(attempt.sessionId ? 'We have not been able to confirm an earlier payment for this invoice yet. Check again in a moment.' : 'An earlier payment for this invoice cannot be checked from this page. It may have gone through, so please contact the practice before paying again.'); }; try { if (!attempt.sessionId) { unresolved(); return; } const status = await getPaymentSessionStatus(attempt.sessionId, attempt.operationId, operation.transport); if (!current()) return; if (status.settled) { writeCheckoutAttempt(viewer, practiceId, id, null); setPayPending(false); await refreshInvoice(id); return; } if (status.sessionStatus === 'expired' && !status.paid) { writeCheckoutAttempt(viewer, practiceId, id, null); setPayPending(false); setPayError('That payment did not go through and nothing was charged. You can pay again.'); return; } unresolved(); } catch (checkError) { if (!current()) return; console.error('Could not check the earlier payment:', checkError); unresolved(); } finally { const still = current(); operation.retire(); if (still) setPaySubmitting(false); } }; const handlePayNow = async (amountCents?: number, recheck = false) => { if (!invoice?.id || environment.inSandboxMode) return; const id = invoice.id; const startedFor = scope; const generation = opening.current.generation; const epoch = viewerEpoch(); const operation = payScope.start(); const current = () => operation.belongs() && opening.current.generation === generation && viewerEpoch() === epoch; const { viewer, practiceId } = checkoutScope(); const intent: CheckoutIntent = amountCents !== undefined ? 'partial' : 'full'; setPaySubmitting(true); setPayAtDesk(false); setPayError(undefined); setPayChanged(undefined); setPayOpen(undefined); const storageFailed = (storageError: unknown) => { console.error('Could not keep the checkout attempt:', storageError); if (current()) { setPaySubmitting(false); setPayError('This device could not prepare the payment for a safe retry. Please try again, or pay at the desk.'); } }; // An unresolved attempt is settled first, by sending exactly what it first sent; a new amount or a new // balance becomes a new operation only once that one has an answer that disposes of it. let existing: CheckoutAttempt | null; try { existing = readCheckoutAttempt(viewer, practiceId, id); } catch (storageError) { storageFailed(storageError); operation.retire(); return; } const reconciling = Boolean(existing); // An attempt kept before its request was stored cannot be sent again as first sent: rebuilt from today's policy // and addresses it would be another request under its id. It is only ever checked, and stays unresolved until a // check settles it. if (existing && !existing.request) { await checkUnreplayable(existing, viewer, practiceId, id, operation, current); return; } let request: CheckoutRequestBody; if (existing) { request = existing.request as CheckoutRequestBody; } else { request = buildCheckoutRequestBody({ invoiceId: id, operationId: crypto.randomUUID(), intent, amountCents, expected: { remainingCents: remainingCents(invoice, payments), currency: (invoice.totalGross?.currency ?? 'USD').toLowerCase(), allowPartial: readInvoiceAllowPartialPay(invoice), }, ...buildCheckoutUrls(window.location.origin, window.location.pathname, window.location.hash), }); } // What was pressed is what is stored: a press of another amount only checks the stored one. const asPressed = recheck || !existing || (request.intent === intent && request.amountCents === amountCents); const attempt: CheckoutAttempt = existing ?? { operationId: request.operationId, invoiceId: id, intent: request.intent, amountCents: request.amountCents, currency: request.expected.currency, expectedRemainingCents: request.expected.remainingCents, createdAt: Date.now(), request, }; try { writeCheckoutAttempt(viewer, practiceId, id, { ...attempt, request }); } catch (storageError) { storageFailed(storageError); operation.retire(); return; } const clear = () => writeCheckoutAttempt(viewer, practiceId, id, null); try { const answered = await createCheckout(request, operation.transport); // An answer for another viewer, practice or invoice than the one now shown neither redirects nor speaks. if (!current()) return; setPayPending(false); // An answer counts only for the operation it was asked about; one naming another leaves this one unknown. const bound = answered.operationId === undefined ? answered.kind === 'refused' || answered.kind === 'pending' : answered.operationId === request.operationId; const outcome: CheckoutOutcome = bound ? answered : { kind: 'pending' }; if (outcome.kind === 'ready') { writeCheckoutAttempt(viewer, practiceId, id, { ...attempt, request, currency: outcome.currency, sessionId: outcome.sessionId }); if (!asPressed) { setPayOpen({ url: outcome.url, amountCents: outcome.amountCents }); return; } window.location.assign(outcome.url); // leaving the SPA for Stripe's hosted page return; // keep the spinner up through navigation } if (outcome.kind === 'pending') { // Outcome unknown: keep the stored id and offer Check again, never a fresh Pay. setPayPending(true); setPayError(asPressed ? 'We have not been able to confirm this payment yet. Check again in a moment.' : 'An earlier payment for this invoice is still being confirmed. Check again before paying another amount.'); return; } if (outcome.kind === 'paid') { clear(); await refreshInvoice(id); return; } if (outcome.kind === 'expired') { clear(); setPayError('That payment did not go through and nothing was charged. You can pay again.'); return; } if (outcome.kind === 'changed') { // No operation was stored under this id, so nothing of it is owed. A confirmed retry, if the // patient continues, starts a brand-new operation id from the refreshed figure. clear(); setPayChanged({ remainingCents: outcome.remainingCents, currency: outcome.currency, allowPartial: outcome.allowPartial }); setPayError('The amount owed has changed. Review it below and pay again to continue.'); await refreshInvoice(id); return; } // A refusal disposes of a retried operation only when it was read from that operation; one about the // retry alone (a reused id, an old handler's bare error) leaves the earlier send unknown. A first send // has nothing earlier, so its refusal ends it - except payment-in-progress, a hold that may be live. if (outcome.code === 'payment_in_progress') { setPayPending(true); setPayError('A payment for this invoice is already under way. Finish it where it was opened, or try again in about half an hour.'); return; } if (reconciling && outcome.scope !== 'operation') { setPayPending(true); setPayError('We have not been able to confirm the earlier payment yet. Check again in a moment.'); return; } clear(); if (outcome.code === 'no_payment_gateway') { setPayAtDesk(true); } else if (outcome.code === 'invoice_not_payable') { setPayError('This invoice can no longer be paid online. Please contact the practice.'); } else if (outcome.code === 'payments_unavailable') { setPayError('This practice cannot take online payments right now. Please pay at the desk or contact the practice.'); } else if (outcome.code === 'forbidden') { setRefusedFor(startedFor); setPayError('You don’t have permission to pay this invoice online. It can still be paid at the desk.'); } else if (outcome.code === 'invalid_redirect_url') { // The server accepts a return address only on the practice's registered portal host, so a // retry from this page is refused the same way. setPayError( "This practice can't take online payment from this page yet. Please pay at the desk or contact the practice.", ); } else if (outcome.code === 'amount_exceeds_balance' || outcome.code === 'invalid_amount' || outcome.code === 'partial_pay_not_allowed') { setPayError('That amount is no longer valid for this invoice. Refresh the page and try again.'); await refreshInvoice(id); } else { setPayError('We could not start the payment. Please try again, or pay at the desk.'); } } catch (checkoutError) { // Never reached in practice - PaymentService.createCheckout never throws - but a defensive // fallback keeps the stored id rather than guessing this is a refusal. console.error('Failed to start checkout:', checkoutError); if (current()) { setPayPending(true); setPayError('We could not confirm whether this payment started. Check again in a moment.'); } } finally { const still = current(); operation.retire(); if (still) setPaySubmitting(false); } }; // Replays the stored request unchanged - never a fresh one - while its outcome is still unknown. const handleCheckAgain = () => { void handlePayNow(undefined, true); }; // Retroactive toggle: allowPartialPay is policy, not frozen financial content, so this works even on // an issued invoice - the server's own narrow endpoint (not Invoice/put.ts) enforces that boundary. const handleTogglePartialPay = async (next: boolean) => { if (!invoice?.id || invoice.status !== 'issued') return; setPartialPaySubmitting(true); setPartialPayError(undefined); try { applyUpdate(await setInvoicePartialPay(invoice.id, next)); } catch (toggleError) { console.error('Failed to update allow-partial-pay:', toggleError); const refused = toggleError instanceof HttpError && toggleError.status === 409 ? (toggleError.body as { message?: string } | undefined)?.message : undefined; if (refused === 'invoice_not_open') { setPartialPayError('This invoice is no longer open, so part payment was not changed.'); await refreshInvoice(invoice.id); } else if (refused === 'invoice_changed') { setPartialPayError('This invoice changed while saving. Nothing was changed; try again.'); await refreshInvoice(invoice.id); } else { setPartialPayError('Failed to update the partial payment setting. Please try again.'); } } finally { setPartialPaySubmitting(false); } }; if (loading) { return

Loading invoice…

; } if (loadError || !invoice) { return

{loadError ?? 'Invoice not found.'}

; } const lines = readInvoiceLines(invoice); // FROZEN readers - the tax stamped on THIS invoice at save time, never re-derived from the org's // current live tax rate (a rate change after issue must not silently reprice a past invoice). const subtotalCents = invoiceSubtotalCents(invoice); const taxCents = invoiceTaxCents(invoice); const totalCents = invoiceTotalCents(invoice); const invoiceNumber = invoiceNumberOf(invoice); // Task 8: partial-payment state derived from the fetched ledger (Task 7's paymentModel). When the // ledger fetch failed/hasn't resolved, `payments` is [] and these safely derive to paid=0, // remaining=totalCents, partiallyPaid=false - the summary block below just doesn't render. const paid = paidCents(payments); const remaining = remainingCents(invoice, payments); const partiallyPaid = isPartiallyPaid(invoice, payments); const history = paymentHistory(payments); // Past-due keys off the REMAINING balance, not just the due date: a partially-paid overdue invoice is // still past due (on what's left); a fully-paid-but-late one is not. const overdue = pastDueWithRemaining(invoice, remaining); const canEdit = canManage && invoice.status === 'draft'; const canIssue = canManage && invoice.status === 'draft'; // Only against a ledger that was read, so the amount offered is what is really still owed. const canRecordPayment = canManage && invoice.status === 'issued' && paymentsRead === 'ready' && remaining > 0; // Mark as Paid records exactly that remaining amount, so it needs the same read ledger. const canMarkPaid = canRecordPayment; const canVoid = canManage && (invoice.status === 'draft' || invoice.status === 'issued'); // Shown whatever the invoice's state or ledger read: a payment sent but never confirmed needs its answer. const pendingPayment = canManage && invoice.id ? readPendingStaffPayment(orgId, invoice.id) : null; // Patient-portal only, and only for an invoice the patient can actually pay (issued, with something // still owed - a fully-paid-but-not-yet-`balanced` invoice, e.g. mid-webhook, shows no pay area). // Checkout refuses a practice with no processor keys. Only the signed-in read of the practice // (it carries meta.versionId) holds the gateway, so the anonymous copy decides nothing. // Checkout also refuses when the Direct Patient Payments add-on is off; only a read answer says so. const addOnOff = !billing?.entitlementsUnavailable && billing?.entitlements?.directPatientPaymentsEnabled === false; const onlinePaymentOff = addOnOff || (!!currentOrganization?.meta?.versionId && !getPaymentGatewayConfig(currentOrganization).configured); const payOffline = patientPay && invoice.status === 'issued' && onlinePaymentOff; // Staff see the same payments-off answer the patient gets. const staffPayOffline = canManage && !patientPay && invoice.status === 'issued' && onlinePaymentOff; // From the patients host the checkout cannot come back to this page yet, so it is not offered there at all. const payFromPractice = patientPay && patientsHost && invoice.status === 'issued'; const canPay = patientPay && !patientsHost && invoice.status === 'issued' && paymentsRead === 'ready' && remaining > 0 && !onlinePaymentOff; // Task 9: the "Pay a different amount" control is opt-in per invoice - only when the invoice's own // allowPartialPay policy flag is set. `canPay` already implies remaining > 0. const canPayPartial = canPay && readInvoiceAllowPartialPay(invoice); const amountText = partialAmountText ?? (remaining / 100).toFixed(2); const partialValidation = parsePartialPayAmount(amountText, remaining); const partialTouched = partialAmountText !== null; // One Pay button (Spencer 09-03): the typed amount when part pay is allowed, else the whole remainder. // The full remainder is sent without an amount so it is charged as the balance. const payAmountCents = canPayPartial && partialValidation.ok && partialValidation.cents < remaining ? partialValidation.cents : undefined; // The sandbox shows what the patient would owe but never opens a checkout; the server refuses too. const sandboxPay = canPay && environment.inSandboxMode; const payDisabled = sandboxPay || paySubmitting || payRefused || (canPayPartial && !partialValidation.ok); // The retroactive control (Task 6): billing:manage staff on any NON-draft invoice - a draft invoice's // allowPartialPay is already editable via the Edit -> InvoiceEditor toggle above, so this is the // "already issued (or beyond)" path. Never shown to non-managers or on the patient-portal view. // Only an open bill can still take part payments; a paid or voided one has nothing left to split. const canTogglePartialPay = canManage && invoice.status === 'issued'; // The emailed pay link: staff send it to whoever the server says pays, and can end every link sent. const payLinkStaff = canManage && !patientPay && !environment.inSandboxMode && invoice.status === 'issued'; const canSendPayLink = payLinkStaff && paymentsRead === 'ready' && remaining > 0 && !onlinePaymentOff; const runPayLink = async (action: 'send' | 'end') => { if (!invoice.id) return; setPayLinkBusy(true); setPayLinkNotice(undefined); if (action === 'send') { setPayLinkNotice(payLinkSentMessage(await sendPayLink(invoice.id))); } else { const ended = await endPayLinks(invoice.id); setPayLinkNotice({ ok: ended, text: ended ? PAY_LINK_ENDED : PAY_LINK_END_FAILED }); } setPayLinkBusy(false); }; // The patient bill dialog keeps its own ruled skin (PatientInvoiceList.css); staff take the shared classes. const ui = (staff: string, patient: string) => (patientView ? patient : staff) || undefined; return (
{!statusInHeader && (
{showInvoiceNumber && (

Invoice {invoiceNumber ?? invoice.id}

)}
{/* Status is coloured text, never a pill; an overdue bill reads Past due in place of Sent. */}
)} {actionError && (
{actionError}
)} {payLinkNotice && (
{payLinkNotice.text}
)} {pendingPayment && !recordOpen && (
A payment of {formatMoney(pendingPayment.cents)} was not confirmed. Confirm it before recording anything else; it will not be recorded twice.
)} {/* Retroactive control: works on an already-issued invoice (allowPartialPay is payment policy, not frozen financial content) — hidden entirely for non-managers/the patient view. */} {canTogglePartialPay && (
void handleTogglePartialPay(checked === true)} />
)} {partialPayError && (
{partialPayError}
)} {/* The lines and their totals are one block, as the deck groups them. */}
Line items {lines.length === 0 ? (

No line items.

) : (
{lines.map((line, index) => (
{line.description || 'Untitled line'} {formatMoney(lineAmountCents(line))}
))}
)}
{taxCents > 0 ? ( <>
Subtotal {formatMoney(subtotalCents)}
Tax {formatMoney(taxCents)}
Total {formatMoney(totalCents)}
) : (
Total {formatMoney(totalCents)}
)}
{/* Task 8: paid/remaining summary + payment history — only when at least one payment has landed (paid > 0). A failed/empty ledger fetch leaves `paid` at 0, so this simply omits itself rather than showing a misleading "$0 of $X paid" line. */} {(paymentsRead === 'failed' || paymentsRead === 'incomplete') && (
{paymentsRead === 'incomplete' ? 'Payments on this invoice could not be read in full, so the amount still owed is not shown.' : 'Payments on this invoice could not be loaded, so the amount still owed is not shown.'}
)} {paid > 0 && (
Paid {formatMoney(paid)} of {formatMoney(totalCents)} · {formatMoney(remaining)} remaining {partiallyPaid && } {remaining === 0 && }
{history.length > 0 && (
{/* Oldest first on the patient's bill, as the deck lists them. */} {(patientView ? [...history].reverse() : history).map((entry, index) => (
{formatDate(entry.dateISO)} {formatMoney(entry.amountCents)}
))}
)}
)} {/* Patient-portal "Pay now" feedback: pay-at-desk is a soft info note (not an error), payError is a recoverable failure — neither is an error wall. */} {payFromPractice && (
{PAY_FROM_PRACTICE_MESSAGE}
)} {(payAtDesk || payOffline) && !payFromPractice && (
This practice has not enabled online payment. Pay at the desk or contact the practice.
)} {staffPayOffline && (
Patients cannot pay this invoice online yet. Record an offline payment here, or connect payments in Billing > Payments setup.
)} {sandboxPay && ( {SANDBOX_DISABLED_TITLE} Payments can't be made from the sandbox. You can still view this invoice. )} {payError && (
{payError}
)} {payOpen && (
An earlier payment of {formatMoney(payOpen.amountCents)} for this invoice is still open.{' '}
)} {payChanged && (
The amount now owed is {formatMoney(payChanged.remainingCents)}.
)} {/* Opt-in per invoice (allowPartialPay). Validation is UX only; the server re-validates. */} {canPayPartial && (
$ setPartialAmountText(event.target.value)} />
{partialTouched && !partialValidation.ok && ( {partialValidation.error} )}
)} {/* Print is always available (not a mutation, not canManage-gated), so this row always renders; the status-only actions stay gated by canManage + the invoice's status. */} {/* The patient's bill prints from an icon, as the deck draws it; staff keep the word. */} {patientView ? ( ) : ( )} {/* Paying is a commit, so it is green, as the patient-portal deck paints it. An attempt whose outcome is still unknown offers Check again on the SAME operation, never a second Pay beside it. */} {canPay && payPending ? ( ) : canPay && ( )} {canEdit && ( )} {canIssue && ( )} {canRecordPayment && ( )} {canSendPayLink && ( )} {payLinkStaff && ( )} {canMarkPaid && ( )} {canVoid && ( )} { applyUpdate(saved); setEditorOpen(false); }} /> { if (!open) { setStatusModal(null); setActionError(undefined); } }} action={statusModal?.action ?? 'void'} submitting={actionSubmitting} error={actionError} paidAmount={formatMoney(remaining)} onConfirm={(status) => void handleConfirmStatus(status)} /> { if (recordSubmitting) return; if (open) setRecordOpen(true); else closeRecordPayment(); }} remainingCents={remaining} lockedCents={lockedCents} allowPartialPay={readInvoiceAllowPartialPay(invoice)} submitting={recordSubmitting} error={recordError} onConfirm={(cents) => void handleRecordPayment(cents)} /> {/* Hidden on screen; revealed only under @media print (index.css .printable-invoice). */}
); }; export default InvoiceDetail;