import { useCallback, useEffect, useRef, useState } from 'react'; import type { Organization } from 'fhir/r4'; import { Heading } from '../../components/ui/heading'; import PracticeDoorCard from '../LoginPage/PracticeDoorCard'; import { PayLinkUnreachable, PayLinkView, useInvoicePayLinkService, } from '../../services/InvoicePayLinkService.tsx'; import { formatMoney } from '../../utils/money'; import { payLinkReturnUrls, readPayLinkOperation, resolvePayLinkToken, returnedFromPayment, writePayLinkOperation, type PayLinkOperation, } from '../../utils/invoicePayLinkToken'; // The page an emailed pay link opens: no sign-in, and nothing about the patient or their care. It shows the // practice, the invoice's reference and what is owed, and hands the payer to the practice's payment page. // "Paid" is only ever the server's answer; coming back from the payment page shows it is being confirmed. type Phase = | { kind: 'loading' } | { kind: 'unreachable' } // `resumable`: the stored operation's checkout is live at the provider, to be continued only when asked. | { kind: 'view'; view: PayLinkView; confirming: boolean; resumable?: boolean }; const CONFIRM_CHECKS = 3; const CONFIRM_WAIT_MS = 3000; const CLOSED_COPY: Record = { paid: { title: 'This invoice is paid', body: 'Thank you. There is nothing left to pay on it.' }, expired: { title: 'This link has expired', body: 'Ask the practice to send you a new one.' }, revoked: { title: 'This link no longer works', body: 'The practice has ended it. Ask them for a new one if you still need to pay.' }, changed: { title: 'This invoice has changed', body: 'The practice changed it after sending this link. Ask them to send you a new one.' }, void: { title: 'There is nothing to pay', body: 'The practice has closed this invoice. Contact them if you have a question about it.' }, invalid: { title: 'This link is not valid', body: 'It may have been cut short by your email program. Open it from the original message again.' }, unavailable: { title: 'Payments are not available right now', body: 'Please try again later, or contact the practice to pay another way.' }, }; const PAY_REFUSAL: Record = { payment_in_progress: 'A payment for this invoice is already under way. Wait a few minutes before trying again.', no_payment_gateway: 'This practice does not take payments online. Contact them to pay another way.', payments_unavailable: 'Payments are not available right now. Please try again later.', }; const STORAGE_REFUSED = 'This browser could not keep this payment safe to retry, so nothing was sent. ' + 'Try again in a regular browser window, or contact the practice to pay another way.'; const EARLIER_UNCONFIRMED = 'We have not been able to confirm the earlier payment yet. Wait a few minutes before trying again.'; const doorOf = (practiceName?: string): Organization | undefined => practiceName ? { resourceType: 'Organization', name: practiceName } : undefined; export const InvoicePayPage = () => { const { openPayLink, startPayLinkCheckout } = useInvoicePayLinkService(); const [token] = useState(() => resolvePayLinkToken(window.location.href)); const [returned] = useState(() => returnedFromPayment(window.location.href)); const [phase, setPhase] = useState({ kind: 'loading' }); const [paying, setPaying] = useState(false); const [payError, setPayError] = useState(); const checks = useRef(0); // Answers belong to the page that asked, and only to its latest question: a closed page, or one that has // asked again since, neither shows nor redirects on an older answer. const alive = useRef(true); const asked = useRef(0); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const ask = () => { const mine = ++asked.current; return () => alive.current && asked.current === mine; }; // A clear that fails leaves the envelope stored; the next open asks about that operation again, which is safe. const clear = (value: string) => { try { writePayLinkOperation(value, null); } catch { /* left stored */ } }; // `open` never creates a checkout; it is handed the stored operation id (if any) so it can answer // THAT operation's own minimal status alongside the practice/reference/balance view. Only a terminal // answer about the stored operation clears it; a live checkout is offered for explicit continuation. const load = useCallback(async () => { const live = ask(); if (!token) { setPhase({ kind: 'view', view: { state: 'invalid' }, confirming: false }); return; } let stored: PayLinkOperation | null = null; try { stored = readPayLinkOperation(token); } catch { // Read without an operation; Pay then refuses unsent, since what was stored is unknown. } try { const view = stored ? await openPayLink(token, stored.operationId) : await openPayLink(token); if (!live()) return; const answered = view.state === 'payable' || view.state === 'paid' ? view.checkout : undefined; const checkout = answered && stored && answered.operationId === stored.operationId ? answered : undefined; const terminal = checkout?.kind === 'paid' || checkout?.kind === 'expired' || (checkout?.kind === 'refused' && checkout.code !== 'payment_in_progress'); if (terminal) clear(token); const stillUnresolved = checkout?.kind === 'pending' || (checkout?.kind === 'refused' && checkout.code === 'payment_in_progress'); setPhase({ kind: 'view', view, confirming: (returned || stillUnresolved) && view.state === 'payable', resumable: checkout?.kind === 'ready' && view.state === 'payable', }); } catch (error) { if (!live()) return; if (error instanceof PayLinkUnreachable) setPhase({ kind: 'unreachable' }); else throw error; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPayLink, returned, token]); useEffect(() => { void load(); }, [load]); // Back from the payment page before the practice's processor has told Akashic, or the operation's // outcome is still unknown: look again a few times via `open` (status only, never a re-POST of pay). const confirming = phase.kind === 'view' && phase.confirming; useEffect(() => { if (!confirming || checks.current >= CONFIRM_CHECKS) return undefined; const timer = window.setTimeout(() => { checks.current += 1; void load(); }, CONFIRM_WAIT_MS); return () => window.clearTimeout(timer); }, [confirming, load, phase]); // A stored operation is replayed exactly as first sent; a fresh press stores its envelope before sending. // Either way, storage that cannot be read or kept refuses before anything leaves. const pay = async () => { if (!token || phase.kind !== 'view' || phase.view.state !== 'payable') return; const shown = phase.view; const live = ask(); setPaying(true); setPayError(undefined); const refuseUnsent = () => { setPayError(STORAGE_REFUSED); setPaying(false); }; let stored: PayLinkOperation | null; try { stored = readPayLinkOperation(token); } catch { refuseUnsent(); return; } let request: PayLinkOperation & { successUrl: string; cancelUrl: string }; if (stored) { const urls = stored.successUrl && stored.cancelUrl ? { successUrl: stored.successUrl, cancelUrl: stored.cancelUrl } : payLinkReturnUrls(window.location.origin, window.location.pathname); request = { ...stored, ...urls }; } else { if (!shown.financialVersion) { setPaying(false); return; } request = { operationId: crypto.randomUUID(), financialVersion: shown.financialVersion, amountCents: shown.amountCents ?? 0, currency: shown.currency ?? 'usd', ...payLinkReturnUrls(window.location.origin, window.location.pathname), }; try { writePayLinkOperation(token, request); } catch { refuseUnsent(); return; } } const reconciling = Boolean(stored); try { const answer = await startPayLinkCheckout( token, request.operationId, request.financialVersion, request.successUrl, request.cancelUrl, ); if (!live()) return; if (answer.kind === 'view') { if (!reconciling) clear(token); setPhase({ kind: 'view', view: answer.view, confirming: false }); setPaying(false); return; } // An answer counts only for the operation it was asked about; one naming another leaves this one unknown. const bound = answer.operationId === undefined ? answer.kind === 'refused' || answer.kind === 'pending' : answer.operationId === request.operationId; const outcome = bound ? answer : { kind: 'pending' as const }; if (outcome.kind === 'ready') { window.location.assign(outcome.url); return; } else if (outcome.kind === 'pending') { setPhase({ kind: 'view', view: shown, confirming: true }); } else if (outcome.kind === 'paid') { clear(token); setPhase({ kind: 'view', view: { state: 'paid', practiceName: shown.practiceName }, confirming: false }); } else if (outcome.kind === 'expired') { clear(token); setPayError('That payment did not go through and nothing was charged. You can pay again.'); setPhase({ kind: 'view', view: shown, confirming: false }); } else if (outcome.kind === 'changed') { // The reviewed view carries the server's new figure and its financial version, so the explicit // fresh press that follows is admitted against what is now shown. clear(token); setPayError('The amount owed has changed. Review it below and pay again to continue.'); setPhase({ kind: 'view', confirming: false, view: { ...shown, amountCents: outcome.remainingCents, currency: outcome.currency, financialVersion: outcome.financialVersion }, }); if (!outcome.financialVersion) void load(); } else if (outcome.code === 'payment_in_progress') { setPayError(PAY_REFUSAL.payment_in_progress); } else if (reconciling && outcome.scope !== 'operation') { // About this retry alone: the operation it retried is still unresolved, and kept. setPayError(EARLIER_UNCONFIRMED); } else { clear(token); setPayError(PAY_REFUSAL[outcome.code] ?? PAY_REFUSAL.payments_unavailable); setPhase({ kind: 'view', view: shown, confirming: false }); } } catch { if (!live()) return; setPayError('We could not reach Akashic just now. Your link still works; please try again.'); } setPaying(false); }; const view = phase.kind === 'view' ? phase.view : undefined; const closed = view && view.state !== 'payable' ? CLOSED_COPY[view.state] ?? CLOSED_COPY.unavailable : undefined; const payable = view?.state === 'payable' ? view : undefined; const waiting = Boolean(payable) && confirming; return (
{phase.kind === 'loading' &&

Opening your invoice...

} {phase.kind === 'unreachable' && ( <> We could not reach Akashic

This is a connection problem, not a problem with your link. Please try again in a moment.

)} {closed && ( <> {closed.title}

{closed.body}

)} {waiting && ( <> Confirming your payment

The practice's payment provider is confirming it. This usually takes a few moments.

{checks.current >= CONFIRM_CHECKS && ( )} )} {payable && !waiting && phase.kind === 'view' && phase.resumable && ( <> Your payment is ready

A payment page was already opened for this invoice. Continue to it; nothing new is started.

{payError &&

{payError}

} )} {payable && !waiting && !(phase.kind === 'view' && phase.resumable) && ( <> Pay your invoice
Invoice
{payable.reference}
Amount due
{formatMoney(payable.amountCents ?? 0, (payable.currency ?? 'usd').toUpperCase())}
{payError &&

{payError}

}

You will pay on the practice's secure payment page. This link opens nothing else.

)}
); }; export default InvoicePayPage;