import { useEffect, useState } from 'react'; import { AlertCircle, Check, CheckCircle2, Copy, Loader2 } from 'lucide-react'; import './PatientPaymentSettingsPage.css'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useOrganizationServiceContext } from '../../../services/OrganizationService.tsx'; import { useBillingContext } from '../../../services/BillingContext.tsx'; import { Button } from '../../../components/ui/button.tsx'; import { Input } from '../../../components/ui/input.tsx'; import { Textarea } from '../../../components/ui/textarea.tsx'; import { Label } from '../../../components/ui/label.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select.tsx'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, } from '../../../components/ui/alert-dialog.tsx'; import { getPaymentGatewayConfig } from '../../../utils/paymentGateway'; import { environment } from '../../../environments/environment'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { describeReadiness, type ReadinessView } from './paymentReadinessCopy'; import { stripeKeyPlaceholders } from '../../../utils/stripePublishableKey'; import { useCapabilityServiceContext } from '../../../services/CapabilityService.tsx'; import { isSaveRetired, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope } from '../../../utils/saveOperation'; import { holdSettingsWrite, nextSettingsAttempt, recordSettingsAttempt, settingsWriteHolder, useUnresolvedSettingsWrite, watchSettingsWrite } from '../../../utils/unresolvedSettingsWrite'; // The server answers a refusal with a 4xx only before it changes anything, and a change it could not finish with 503. // A 412 here can only come from a server that has not had that fix yet, after the secret had already changed: it is // treated as unconfirmed too. const refusedBeforeWriting = (status: number) => status < 500 && status !== 412; const keyHints = stripeKeyPlaceholders(environment.production); // Invoicing Phase 3 (direct patient payments, BYO processor): "Payments setup", where an org drops its // OWN Stripe keys so patients pay the practice directly (Akashic is never merchant of record). // // - IT LIVES IN BILLING NOW (`/dashboard/billing/payments`), not Platform Manager. This // setting decides whether a patient can pay a bill, so it belongs beside the bills: a biller who cannot // find why payments are off is a biller who cannot do their job. // And its old home is why exists - the setup checklist reached into Platform Manager, a // workspace the founding admin did not have, so they could not connect payments and could not finish // onboarding, with nothing on screen saying why. // // Was originally a section on the Public Form Settings page; moved to its own page 2026-07-19. Tailwind // utilities inline (the newer invoicing code is all Tailwind - no per-page.css file). // // SECURITY: the secret key + webhook signing secret are WRITE-ONLY. The server never returns them, // and this component never renders/logs a stored value - a configured secret shows only as a // placeholder. canSave requires ALL of gatewayId + publishableKey + secretKey + webhookSecret to // be non-blank: the endpoint OVERWRITES the stored secret on every configure save and the secret // field is write-only, so a save with a blank secret would silently wipe the stored credential. // Requiring full re-entry on every save is the guard against that. // v1 supports a single gateway; the Select exists so adding processors later is a data change. // Nothing is chosen until the practice picks it: a not-connected page fills in nothing. const GATEWAY_OPTIONS: Array<{ value: string; label: string }> = [{ value: 'stripe', label: 'Stripe' }]; // At rest a long key reads as its mode prefix, four characters and its last four, as the connected frame draws it. const abbreviateKey = (key: string): string => { const prefix = key.match(/^[a-z]+_(?:live|test)_/)?.[0] ?? ''; return key.length > prefix.length + 12 ? key.slice(0, prefix.length + 4) + String.fromCharCode(8230) + key.slice(-4) : key; }; // One sentence for the missing add-on, whether the page finds it before the form or the server refuses a // save for it. The server's refusal carries only a code (payments_addon_required, 403) and no message, so // the generic denial path would call it a permission problem when it is a subscription one. const ADDON_REQUIRED_MESSAGE = "Direct patient payments are not enabled in your organization's subscription. Contact an administrator to add this feature."; const isAddonRequired = (error: unknown): boolean => (error as { body?: { error?: unknown } } | null | undefined)?.body?.error === 'payments_addon_required'; const PatientPaymentSettingsPage = () => { const { currentOrganization, setWpOrganization } = useAuthServiceContext(); const { updatePaymentSettings, readPaymentReadiness } = useOrganizationServiceContext(); // Gate the whole key-config form on the Direct Patient Payments subscription add-on. const { entitlements, entitlementsUnavailable, refreshBilling, isLoading: billingLoading } = useBillingContext(); const [gatewayId, setGatewayId] = useState(''); const [publishableKey, setPublishableKey] = useState(''); const [editingKey, setEditingKey] = useState(false); const [secretKey, setSecretKey] = useState(''); const [webhookSecret, setWebhookSecret] = useState(''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(); const [successMessage, setSuccessMessage] = useState(); const [disconnectOpen, setDisconnectOpen] = useState(false); const [disconnecting, setDisconnecting] = useState(false); const [disconnectError, setDisconnectError] = useState(); const [copied, setCopied] = useState(false); // Configure and disconnect belong to one practice, viewer (AuthService's key names the practice too), add-on // standing and right to configure. When any of them changes, or the page closes, what was started before sends // nothing more and shows nothing; typed secrets and notices go with it. const { mayCall } = useCapabilityServiceContext(); const addOn = entitlements?.directPatientPaymentsEnabled === true; const owner = `${currentOrganization?.id ?? ''}|${addOn ? 'add-on' : 'none'}|${mayCall('PUT', '/payment-settings') === false ? 'refused' : 'may'}`; const scope = useSaveScope(owner, () => { setSecretKey(''); setWebhookSecret(''); setSaving(false); setDisconnecting(false); setSaveError(undefined); setSuccessMessage(undefined); setDisconnectOpen(false); setDisconnectError(undefined); setReadinessAttempt((n) => n + 1); }); // A configure or disconnect sent and never answered: which keys are stored is not known until a deliberate, // confirmed configure or disconnect settles it. Neither the credential flag nor a readiness check can. const holder = settingsWriteHolder('payment-settings'); const unresolved = useUnresolvedSettingsWrite(holder); // `configured` is derived LIVE from currentOrganization (not seeded into state) on purpose: // handleSave/handleDisconnect call setWpOrganization(organization) with a NEW object that keeps // the SAME id, so an id-keyed seed effect would NOT re-fire - a seeded `configured` flag would go // stale (never flip to Connected after the first save, never clear after Disconnect). Deriving it // each render off currentOrganization keeps the indicator + Disconnect button correct. // (The same live secretConfigured/configured read every credential-backed org setting uses.) const config = getPaymentGatewayConfig(currentOrganization ?? undefined); const { configured } = config; // Whether Stripe will actually take payments on the saved keys, asked again after a save or on request. const [readiness, setReadiness] = useState({ state: 'checking' }); const [readinessAttempt, setReadinessAttempt] = useState(0); // Each check belongs to this opening, and only the newest may show: a new one retires the last as it starts. useEffect(() => { if (!configured) { setReadiness({ state: 'not-configured' }); return; } const operation = scope.start(); setReadiness({ state: 'checking' }); operation.step(() => readPaymentReadiness(operation.transport)) .then((answer) => setReadiness(answer)) .catch((error: unknown) => { if (!isSaveRetired(error) && operation.belongs()) setReadiness({ state: 'unknown' }); }) .finally(() => operation.retire()); return () => operation.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [configured, currentOrganization?.id, readinessAttempt]); const readinessCopy = describeReadiness(readiness); // Seed only the EDITABLE public inputs (gateway choice + publishable key). Secret + webhook always // start blank (write-only). Keyed on the org ID (not the object) for the same reason as above. useEffect(() => { const seeded = getPaymentGatewayConfig(currentOrganization ?? undefined); setGatewayId(seeded.gatewayId || ''); setPublishableKey(seeded.publishableKey); setSecretKey(''); setWebhookSecret(''); setSaveError(undefined); setSuccessMessage(undefined); setDisconnectOpen(false); setDisconnectError(undefined); setCopied(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentOrganization?.id]); // The organization can load before its payment settings under the same id; fill an untouched field then. useEffect(() => { if (config.publishableKey) setPublishableKey((current) => current || config.publishableKey); }, [config.publishableKey]); const webhookUrl = `${environment.fhirBaseApi}/payment/webhook/${currentOrganization?.id ?? ''}`; // Full re-entry required on every configure save (see the security note above). const canSave = !!gatewayId.trim() && !!publishableKey.trim() && !!secretKey.trim() && !!webhookSecret.trim(); // While a write is held, every write is the next attempt of that change: the server runs it only after every earlier // attempt and refuses any that arrives later, however many were lost. An attempt that does not settle the hold is // still carried by it, so the next one goes past it too. Only a confirmed answer ends the hold. const handleSave = async () => { if (!canSave || unresolved?.state === 'unconfirmed') { return; } const operation = scope.start(); const heldFor = holder; const sent = { secretKey, webhookSecret }; const startedWith = unresolved; const sending = nextSettingsAttempt(startedWith); // Admitted again after the token and proof waits, and before its answer is used or installed. const write = watchSettingsWrite((onSend) => updatePaymentSettings({ gatewayId, publishableKey: publishableKey.trim(), secretKey: secretKey.trim(), webhookSecret: webhookSecret.trim(), operationId: sending.id, attempt: sending.attempt, }, { ...operation.transport, onSend }), refusedBeforeWriting); setSaving(true); setSaveError(undefined); setSuccessMessage(undefined); try { const { organization } = await operation.step(write.run, true); holdSettingsWrite(heldFor, undefined); setWpOrganization(organization); // Clear the write-only inputs so a stored secret is never left sitting in the DOM: the ones this save // sent. Keys typed while it waited are a newer draft, not saved. setSecretKey((typed) => (typed === sent.secretKey ? '' : typed)); setWebhookSecret((typed) => (typed === sent.webhookSecret ? '' : typed)); setSuccessMessage('Payment settings saved.'); setReadinessAttempt((n) => n + 1); } catch (err) { // Left with no answer, these keys may be stored, and the hold stays with whoever sent them even after they // move on; but a retired attempt never replaces a newer change or revives a settled one. recordSettingsAttempt(heldFor, sending, startedWith, write.unconfirmed() ? { kind: 'configure', state: 'unconfirmed' } : undefined); if (isSaveRetired(err) || !operation.belongs() || write.unconfirmed()) return; console.error('Error saving payment settings:', err); setSaveError( isAddonRequired(err) ? ADDON_REQUIRED_MESSAGE : relayServerFailure(err, 'Failed to save payment settings. Please try again.'), ); } finally { if (operation.belongs()) setSaving(false); operation.retire(); } }; const handleDisconnect = async () => { if (unresolved?.state === 'unconfirmed') return; const operation = scope.start(); const heldFor = holder; const startedWith = unresolved; const sending = nextSettingsAttempt(startedWith); // All-blank body = the endpoint's remove flow (clears the Org extension + deletes the secret). const write = watchSettingsWrite((onSend) => updatePaymentSettings({ gatewayId: '', publishableKey: '', secretKey: '', webhookSecret: '', operationId: sending.id, attempt: sending.attempt, }, { ...operation.transport, onSend }), refusedBeforeWriting); setDisconnecting(true); setDisconnectError(undefined); try { const { organization } = await operation.step(write.run, true); holdSettingsWrite(heldFor, undefined); setWpOrganization(organization); setGatewayId(''); setPublishableKey(''); setSecretKey(''); setWebhookSecret(''); setSaveError(undefined); setDisconnectOpen(false); setSuccessMessage('Disconnected.'); } catch (err) { recordSettingsAttempt(heldFor, sending, startedWith, write.unconfirmed() ? { kind: 'disconnect', state: 'unconfirmed' } : undefined); if (isSaveRetired(err) || !operation.belongs()) return; if (write.unconfirmed()) { setDisconnectOpen(false); return; } console.error('Error disconnecting payment gateway:', err); setDisconnectError( relayServerFailure(err, 'Failed to disconnect. Please try again.'), ); } finally { if (operation.belongs()) setDisconnecting(false); operation.retire(); } }; // Settling an unconfirmed write is deliberate: the full keys entered and saved again, or a disconnect, confirmed. const settleAgain = () => { holdSettingsWrite(holder, { ...(unresolved ?? { kind: 'configure' }), state: 'recovering' }); setSecretKey(''); setWebhookSecret(''); setSuccessMessage(undefined); }; const held = unresolved?.state === 'unconfirmed'; const handleCopyWebhookUrl = async () => { try { await navigator.clipboard.writeText(webhookUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Clipboard can be unavailable (permissions / insecure context); the field is selectable // as a fallback, so silently ignore. } }; // While billing is still loading, show a spinner rather than briefly flashing the gate before // the entitlement is known. (entitlements is null until the first fetch resolves.) if (billingLoading) { return (

Patient Payment Settings

Loading…
); } /** * WE ASKED AND WERE REFUSED, WHICH IS NOT THE SAME AS THE PRACTICE NOT HAVING BOUGHT IT. * * MEASURED: the entitlements read answers 403 for a Platform Manager, and the branch below then * told them direct patient payments are not on the practice's subscription - a confident claim * about a plan this page never managed to read. It is the same rule as an empty chart, in prose: * a refusal must render as a refusal. * * NO RETRY UNDER A DENIAL, and the sentence says the retry is not there. Trying again cannot * change a permission, and a button that quietly does nothing reads as a broken page. */ if (entitlementsUnavailable === 'denied') { return (

Patient Payment Settings

Your role does not include reading this organization's subscription, so this page cannot show whether direct patient payments are part of it. Nothing is wrong with the setting and there is nothing to retry — an administrator can tell you where it stands.
); } /** * THE READ FAILED, WHICH IS ALSO NOT AN ANSWER ABOUT THE SUBSCRIPTION. A fault is retryable where * a refusal is not, so this one keeps the button the branch above deliberately drops. */ if (entitlementsUnavailable === 'failed') { return (

Patient Payment Settings

We could not read your organization's subscription just now, so this page cannot say whether direct patient payments are part of it. Nothing has changed.
); } // Add-on not present on the org's subscription: gate the key form. The PM is not an admin, so this // is the "contact an administrator" variant (no Upgrade button) - mirrors BillingLimitModal's tone. // Reached only once the read SUCCEEDED, so it is now a statement the page has earned. // An unconfirmed change still has to be settled, and removing the keys never needs the add-on (the server does // not gate its remove flow on it), so the hold and a disconnect stay offered here. if (!entitlements?.directPatientPaymentsEnabled) { return (

Patient Payment Settings

{ADDON_REQUIRED_MESSAGE}
{unresolved && ( {TIMED_OUT_MAY_HAVE_SAVED} {disconnectError &&
{disconnectError}
} {held ? ( ) : ( )}
)}
); } return (

Patient Payment Settings

{readinessCopy.tone === 'success' && } {readinessCopy.tone === 'error' && } {readinessCopy.label}

Connect your practice's own Stripe account so patients pay you directly. Akashic charges as your practice and never holds the funds — payments go straight to your account.

{readinessCopy.detail && (
{readinessCopy.detail} {readinessCopy.canRecheck && ( )}
)}
{held && ( {TIMED_OUT_MAY_HAVE_SAVED} )} {unresolved?.state === 'recovering' && ( Save the full keys again, or disconnect. Until one of them is confirmed, which keys are stored is not known. )} {saveError && ( {saveError} )} {successMessage && ( {successMessage} )}
setEditingKey(true)} onBlur={() => setEditingKey(false)} onChange={(event) => { setPublishableKey(event.target.value); setSuccessMessage(undefined); }} />
{ setSecretKey(event.target.value); setSuccessMessage(undefined); }} />
{ setWebhookSecret(event.target.value); setSuccessMessage(undefined); }} />
{/* A textarea so the long URL wraps in full, as the frame shows it. */}