import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ExternalLink, Receipt } from 'lucide-react'; import { useHttpService, useAuthServiceContext } from '../../services'; import { useBillingContext } from '../../services/BillingContext.tsx'; import { AppHeader, AppSpinner, ScrollableTab } from '../../components'; import { environment } from '../../environments/environment.ts'; import { formatMoney } from '../../utils/money'; import { describeServerRefusal } from '../../utils/serverRefusalMessage'; import { buildSubscriptionRows, computeDraftDelta, type SubscriptionRowKey } from './subscriptionRows'; import { StripeCheckoutModal } from './StripeCheckoutModal'; import { CheckAgainButton, CheckoutOutcome, receiptsNoteMessage, slowSettlementMessage, type CheckoutOutcomeState } from './CheckoutOutcome'; import { useFoundingCheck, useOwnerSession, usePurchaseRecovery, type ReadReceipts } from './usePurchaseRecovery'; import { checkFounding, foundingSentence, isFoundingState } from '../../lib/billingCheckout.ts'; import { isPermanentDenial } from '../../utils/fetchFailureMessage.ts'; import { chargeDateLabel } from './chargeDate'; import { practiceTimeZoneOf } from '../../utils/practiceTimezone'; import { describeReceipt, type Receipt as ReceiptRow } from './receiptRows'; import SubscriptionRowItem from './SubscriptionRowItem'; import { resolveSubscriptionWarning } from '../../components/SubscriptionWarningBanner/subscriptionWarningCopy'; import { useSponsoredBilling } from '../../components/SubscriptionWarningBanner/useSponsoredBilling'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../../components/ui/alert-dialog'; // THE SETTLED SUBSCRIPTION PAGE - §4, approved by Spencer 2026-08-14. // // A SUBSCRIPTION PAGE IS A BILL YOU CAN READ AND ACT ON. What this replaces was a plan pill, a status // pill, one storage meter, two buttons and - on paid plans - the raw Stripe subscription and customer // ids. It never stated a price, a total or a renewal date, and the core revenue line (clinician seats) // was not on it at all. // // THE PAGE IS THE UPGRADE VIEW (decision 1). No "Upgrade Plan" button that navigates away, no // separate add-ons page, no second screen to keep in step. That is not only fewer clicks: it makes the // defect that raised this card structurally hard to repeat. If every purchasable thing is a row on a // page an admin always sees, a MISSING one is obvious. // // NO "Custom Plan" (decision 12). That was not a plan name, it was a shrug, and it told a practice we // did not know what they had bought. const encodeForStripe = (value: string): string => btoa(value).replace(/\+/g, '_').replace(/\//g, '-').replace(/=/g, ''); /** * THREE STATES, NOT TWO. `undefined` means "we have not looked yet", `null` means "we looked and * could not find out", and an array - including an empty one - is an answer. Collapsing the middle one * into `[]` is what makes a failed read say "you have no receipts", which is a false statement about a * practice's money and the exact class the server's 502 exists to keep distinguishable. */ type ReceiptsState = ReceiptRow[] | null | undefined; /** How long the checkout request may take before the page says what it knows about it. */ const CHECKOUT_ALLOWANCE_MS = 30_000; /** How long the page's own receipts read may take before the list says it could not load. */ const RECEIPTS_ALLOWANCE_MS = 30_000; const SENT_UNANSWERED = 'We did not hear back about this change, so we cannot say whether it went through. Check your subscription and receipts before trying again.'; const UPDATED_UNREAD = 'Your change was made. We could not refresh your billing details just now, so the figures above may be out of date.'; const SENT_FAILED_UNSURE = 'Billing reported a problem with this change, so we cannot say whether it went through. Check your subscription and receipts before trying again.'; const statusOf = (error: unknown): number | undefined => { const status = (error as { status?: unknown } | null | undefined)?.status; return typeof status === 'number' ? status : undefined; }; // Only these answers prove the change was refused before anything was applied: a 409 is the checkout's refusal // contract, and a named `reason` is a refusal the handler wrote itself. Any other status can follow an applied change. const refusedBeforeEffect = (error: unknown): boolean => { const status = statusOf(error); if (status === undefined || status >= 500) return false; if (status === 409) return true; const reason = ((error as { body?: unknown } | null | undefined)?.body as { reason?: unknown } | null | undefined)?.reason; return typeof reason === 'string' && reason.length > 0; }; // The checkout's answer when it could not confirm where a founding stands; it states that nothing more was charged. const foundingUnconfirmed = (error: unknown): string | undefined => { const body = (error as { body?: unknown } | null | undefined)?.body as { code?: unknown; error?: unknown } | null | undefined; return body?.code === 'FOUNDING_UNCONFIRMED' && typeof body.error === 'string' ? body.error : undefined; }; const SubscriptionPage: React.FC = () => { const { get, post } = useHttpService(); const { userRole, USER_ROLES, currentOrganization, actingAsSessionRef } = useAuthServiceContext(); // Billing is the practice's, so its dates are the practice's calendar days. const practiceZone = practiceTimeZoneOf(currentOrganization) ?? undefined; const { baseLimits, addOnPricing, usage, billingStatus, billingMode, subscription, otherActiveSubscriptions, clinicianCount, clinicianCountSettling, addOns, isLoading, billingChecked, refreshBilling, smsTransactionalConfigured, dunning, billingOwner, } = useBillingContext(); const isAdminViewer = userRole === USER_ROLES.ADMIN; const sponsorship = useSponsoredBilling(billingMode, isAdminViewer && Boolean(dunning) && dunning?.stage !== 'none'); // The sentence a failing subscription gets is the one written for it, never the status token. // This printed "Your subscription is grace." to an administrator a week from losing access: no // failure named, no deadline, nothing to press. `resolveSubscriptionWarning` already holds the // wording, the days remaining and the admin/non-admin split. const subscriptionNotice = useMemo(() => { if (!billingStatus || billingStatus === 'active' || billingStatus === 'free_basic') return null; const warning = resolveSubscriptionWarning(dunning, isAdminViewer, sponsorship); if (warning) return warning.message; // A live dunning stage it withheld (admin-only, read in another hat) is a practice still working, // not an inactive one; only a state the resolver has nothing to say about gets the fallback. if (dunning && dunning.stage !== 'none') return null; return 'Your subscription is not active. Update your payment method to restore access.'; }, [billingStatus, dunning, isAdminViewer, sponsorship?.owner]); const [draft, setDraft] = useState>>({}); const [committingOpening, setCommittingOpening] = useState(null); const committing = committingOpening !== null; const checkoutAbort = useRef(null); useEffect(() => () => { checkoutAbort.current?.abort(); }, []); const [commitError, setCommitError] = useState(null); // What a change the server acknowledged still needs said once the draft and its bar are gone. const [changeNote, setChangeNote] = useState(null); const [portalLoading, setPortalLoading] = useState(false); // A DEAD BUTTON IS WORSE THAN A REFUSED ONE. The failure below used to reach a console.error and // nothing else, so an administrator who could not open their card saw the spinner stop and no // reason at all - and this product's own rule is that a refusal renders as a refusal. const [portalError, setPortalError] = useState(null); const [receipts, setReceipts] = useState(undefined); const [moreReceipts, setMoreReceipts] = useState(false); // How the latest receipts read ended, whoever started it, so a failure is shown with or without the payment dialog. const [receiptsStatus, setReceiptsStatus] = useState<'loaded' | 'unreadable' | 'refused' | null>(null); // - the embedded payment modal, and the honest wait after it. const [checkoutSecret, setCheckoutSecret] = useState(null); // After the payment the dialog stays: pending until our state agrees, then what was added. const [checkoutOutcome, setCheckoutOutcome] = useState(null); // Every receipts read - the page's own, after a confirmation, and inside Check again - goes through here. Only the // latest read may publish, only for the owner it named, only while its caller still wants it and only by its // deadline; starting a read ends the one before it. const ownerIsLive = useOwnerSession(billingOwner.generation, actingAsSessionRef); const receiptsSeq = useRef(0); const receiptsAbort = useRef(null); useEffect(() => () => { receiptsSeq.current += 1; receiptsAbort.current?.abort(); }, []); const readReceipts: ReadReceipts = async (owner, deadline, stillWanted, signal) => { const seq = ++receiptsSeq.current; receiptsAbort.current?.abort(); const controller = new AbortController(); receiptsAbort.current = controller; if (signal?.aborted) controller.abort(); signal?.addEventListener('abort', () => controller.abort(), { once: true }); const live = () => receiptsSeq.current === seq && ownerIsLive(owner.generation) && stillWanted(); const timer = window.setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now())); const transport = { organizationId: owner.practiceId ?? undefined, signal: controller.signal, // Also refused once the allowance has elapsed, even before its timer has run: nothing is sent late. admit: () => { if (controller.signal.aborted || !live() || Date.now() >= deadline) throw new DOMException('Receipts read ended.', 'AbortError'); }, }; let answer: { response: { receipts?: ReceiptRow[]; hasMore?: boolean } } | { error: unknown }; try { // The deadline settles the read at once, whatever the request is still waiting on. answer = { response: await new Promise<{ receipts?: ReceiptRow[]; hasMore?: boolean }>((resolve, reject) => { controller.signal.addEventListener('abort', () => reject(new DOMException('Receipts read ended.', 'AbortError')), { once: true }); get<{ receipts?: ReceiptRow[]; hasMore?: boolean }>(environment.apiUrl + '/Stripe/invoices', transport).then(resolve, reject); }) }; } catch (error) { answer = { error }; } finally { window.clearTimeout(timer); if (receiptsAbort.current === controller) receiptsAbort.current = null; } if (!live()) return 'retired'; // A list never loaded shows as unreadable rather than loading for ever; a loaded one is kept. const failed = (read: 'unreadable' | 'refused') => { setReceipts((current) => (current === undefined ? null : current)); setReceiptsStatus(read); return read; }; if (Date.now() >= deadline) return failed('unreadable'); if ('error' in answer) { const status = (answer.error as { status?: unknown } | null | undefined)?.status; return failed(typeof status === 'number' && isPermanentDenial(status) ? 'refused' : 'unreadable'); } // NO `?? []`. An absent list is a read that did not answer, and coalescing it here reintroduces the false empty. if (!Array.isArray(answer.response?.receipts)) return failed('unreadable'); setReceipts(answer.response.receipts); setMoreReceipts(answer.response.hasMore === true); setReceiptsStatus('loaded'); return 'loaded'; }; const recovery = usePurchaseRecovery({ billingOwner, ownerIsLive, refreshBilling, readReceipts, onConfirmed: (confirmed) => { setCheckoutOutcome((current) => (current ? { phase: 'confirmed', ...confirmed } : current)); setDraft({}); }, // Another viewer, practice, acting-as or session: nothing of the last purchase stays actionable. onRetired: () => { checkoutAbort.current?.abort(); setCommittingOpening(null); setDraft({}); setPendingConfirm(false); setCommitError(null); setChangeNote(null); setCheckoutOutcome(null); setCheckoutSecret(null); setReceipts(undefined); setMoreReceipts(false); setReceiptsStatus(null); }, }); const foundingStanding = useFoundingCheck({ wanted: isAdminViewer && billingChecked && billingMode === 'free_basic', owner: billingOwner.generation, ownerIsLive, check: (signal) => checkFounding((url, data) => post(url, data, { organizationId: billingOwner.practiceId ?? undefined, signal })), }); useEffect(() => { if (foundingStanding === 'provider-complete') void refreshBilling().catch(() => undefined); // eslint-disable-next-line react-hooks/exhaustive-deps }, [foundingStanding]); // The page's own read, once per owner. useEffect(() => { void readReceipts(billingOwner, Date.now() + RECEIPTS_ALLOWANCE_MS, () => true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [billingOwner]); const rows = useMemo(() => { if (!addOnPricing || !baseLimits) return []; return buildSubscriptionRows({ pricing: { unitPriceCents: addOnPricing.unitPriceCents ?? {}, smsRegistrationOneTimeCents: addOnPricing.smsRegistrationOneTimeCents, }, addOns: addOns ?? {}, baseLimits, usage: { storageBytesUsed: usage?.storageBytesUsed ?? 0, // THE DAY A COUNTER EXISTS HAS ARRIVED, and this stayed unpassed after it did. The // server keeps a monthly per-organization count, refuses the video session past the // allowance with a 402, and sends the derived allowance in `usage.telehealth`. The // comment that used to sit here said nothing counted them and promised the meter would // appear the day something did. It does, so it does. // // STILL UNDEFINED RATHER THAN 0 WHEN THE SERVER SAYS NOTHING. An older api omits the // field, and "0 / 20 used" would assert a practice has hosted none - the // measurement-we-never-took mistake this surface has shipped four times. telehealthVisitsThisMonth: usage?.telehealth?.used, }, // 1, not 0, when the count is unknown: a zero would price every per-clinician line at // nothing and show a practice a total it does not owe. 1 is the smallest true practice. clinicianCount: clinicianCount ?? 1, smsTransactionalConfigured, }); }, [addOnPricing, addOns, baseLimits, usage, clinicianCount, smsTransactionalConfigured]); const delta = useMemo(() => computeDraftDelta(rows, draft), [rows, draft]); // ONLY THE ROWS THAT MULTIPLY ARE AFFECTED. SMS, campaigns and the scribe are priced per // organization, so a settling clinician count says nothing about them and withholding their price // would be a refusal with no cause - the opposite defect, and the one that reads as a broken screen. const priceIsSettling = clinicianCountSettling && delta.changedKeys.some((key) => rows.find((row) => row.key === key)?.unit === 'clinician'); const hasChanges = delta.changedKeys.length > 0; // - AFTER THE PAYMENT, WAIT FOR *OUR* STATE. // // Stripe's `onComplete` means Stripe accepted the card. It does NOT mean the practice has what they // bought, and the gap is measurable: on the payment that motivated this card Stripe accepted at // 02:23:18Z and our BillingProfile did not reflect it until 02:24:23Z - 65 seconds. Announcing the new // plan from the callback would have been wrong for over a minute, and on that same payment the // entitlement never turned on at all, so it would have been wrong about the capability too. // // THE SETTLE CONDITION IS THE ADMIN'S OWN REQUEST, not a status string: the draft they submitted no // longer differs from what the server says they have. That is the only statement worth making, and it // is true whether the change was an upgrade, a downgrade or a swap that left the total unchanged. const handleRowChange = (key: SubscriptionRowKey, quantity: number) => { setCommitError(null); setDraft((current) => ({ ...current, [key]: quantity })); }; // THE REVIEW HALF OF "REVIEW & PAY", WHICH THE BUTTON PROMISED AND DID NOT DO. // // The screen says at the top: "Adjust anything here - nothing is charged until you review and pay." // Pressing the button went straight to `/Stripe/checkout`, and when the server answers by UPDATING an // existing subscription - no clientSecret, no checkoutUrl - the change was simply applied and the // page refreshed. A practice's bill went from $80.00 to $179.00 a month with no dialog, no summary // and no confirmation, and the same press in reverse took it back. Only the path that opens Stripe's // own embedded form ever asked anything, so whether money moved silently depended on which branch // the server happened to take. // // `Discard changes` sitting beside it is the tell that a review step was always intended. // // THE PATTERN IS THE ONE THIS PRODUCT ALREADY USES for a costly action, one screen away: the // clinician-seat confirm states the amount and what it takes the total to, and names the money in // its action. Everything this needs to say is already computed for the footer - what changed, the // delta, any one-time fee, and the new monthly total - so the dialog states the same facts the // footer does rather than deriving a second set that could disagree with it. const [pendingConfirm, setPendingConfirm] = useState(false); const handleReviewAndPay = async () => { setPendingConfirm(false); // Sent only for the owner and practice it was prepared under, within its allowance. Whether it was sent // decides what a timeout may say, and its answer, error and busy state belong to this opening alone. // A press kept from an earlier owner, session or mount opens nothing. const opening = recovery.beginOpening(); if (!opening) return; setCommitError(null); setChangeNote(null); setCommittingOpening(opening.seq); checkoutAbort.current?.abort(); const controller = new AbortController(); checkoutAbort.current = controller; const deadline = Date.now() + CHECKOUT_ALLOWANCE_MS; let admissions = 0; let timedOut = false; // The server applied the change; nothing after this may say it was refused or not charged. let acknowledged = false; const expired = () => Date.now() >= deadline; const timer = window.setTimeout(() => { timedOut = true; controller.abort(); }, CHECKOUT_ALLOWANCE_MS); const transport = { organizationId: opening.practiceId ?? undefined, signal: controller.signal, // HttpService admits before preparing and again immediately before sending, so a second admission is a send. // An elapsed allowance refuses even before its timer has run, so a held preparation is never sent late. admit: () => { if (controller.signal.aborted || !recovery.openingIsCurrent(opening)) throw new DOMException('Checkout ended.', 'AbortError'); if (expired()) { timedOut = true; throw new DOMException('Checkout ran out of time.', 'AbortError'); } admissions += 1; }, }; const bounded = (pending: Promise) => new Promise((resolve, reject) => { controller.signal.addEventListener('abort', () => reject(new DOMException('Checkout ended.', 'AbortError')), { once: true }); pending.then(resolve, reject); }); try { // Only the DELTA is sent - the server applies it against what the subscription already // carries, so a row the admin did not touch is never re-purchased. const changes = Object.fromEntries( delta.changedKeys.map((key) => [key, (draft[key] ?? 0) - (rows.find((r) => r.key === key)?.quantity ?? 0)]), ); const response = await bounded(post<{ checkoutUrl?: string; clientSecret?: string; updated?: boolean; error?: string; state?: unknown }>( `${environment.apiUrl}/Stripe/checkout`, { addOns: changes, successUrl: `${window.location.origin}${window.location.pathname}#/dashboard/subscription`, cancelUrl: window.location.href, // - pay in a modal rather than being thrown to checkout.stripe.com. embedded: true, /* THE NUMBER THIS SCREEN PRICED AGAINST, so the server can refuse if it has moved. The clinician count is derived from a search index that lags a seat change by a minute or two, so a quote and a charge built either side of that window disagree - the admin sees one figure, presses a button naming it, and is asked for another. IT DECIDES NOTHING. The server reads the count itself and prices from its own read; this states what was displayed so the two can be compared. Sending the price instead would be the client deciding what a practice is billed, which is the thing every comment in this area refuses. */ quotedClinicianCount: clinicianCount, }, transport, )); if (!recovery.openingIsCurrent(opening)) return; if (Date.now() >= deadline) { timedOut = true; throw new DOMException('Checkout answered after its allowance.', 'AbortError'); } if (response.clientSecret) { const admitted = recovery.admitOpening(opening, { added: delta.changedKeys .filter((key) => (draft[key] ?? 0) > (rows.find((r) => r.key === key)?.quantity ?? 0)) .map((key) => rows.find((r) => r.key === key)?.name ?? '') .filter(Boolean), previousTotalCents: subscription?.monthlyTotalCents ?? 0, requested: Object.fromEntries(delta.changedKeys.map((key) => [key, draft[key] ?? 0])), secret: response.clientSecret, }); if (admitted) { setCheckoutOutcome(null); setCheckoutSecret(response.clientSecret); } return; } if (response.checkoutUrl) { setCommitError('We could not open the payment form here. Nothing has been charged.'); return; } // A purchase the server already holds for this practice: nothing was applied, so the draft stays. if (isFoundingState(response.state)) { setChangeNote(foundingSentence(response.state)); if (response.state === 'provider-complete') await bounded(refreshBilling({ signal: controller.signal, deadline })).catch(() => undefined); return; } // An ordinary update the server applied. Its follow-up read keeps this opening's cancellation and allowance. acknowledged = true; setDraft({}); const read = await bounded(refreshBilling({ signal: controller.signal, deadline })); if (recovery.openingIsCurrent(opening) && read.kind !== 'answered') setChangeNote(UPDATED_UNREAD); } catch (error) { if (!recovery.openingIsCurrent(opening)) return; if (acknowledged) { setChangeNote(UPDATED_UNREAD); return; } const sent = admissions >= 2; if (timedOut || expired()) { setCommitError(sent ? 'We did not hear back about this change in time, so we cannot say whether it went through. Check your subscription and receipts before trying again.' : 'We could not reach billing in time. Nothing was sent and nothing has been charged.'); return; } // Only an answer from the server settles the outcome. A sent request that got none, or whose answer could not // be read, does not prove nothing happened, and is never resubmitted for the admin. if (statusOf(error) === undefined) { setCommitError(sent ? SENT_UNANSWERED : 'We could not reach billing. Nothing was sent and nothing has been charged.'); return; } const unconfirmed = foundingUnconfirmed(error); if (unconfirmed) { setCommitError(unconfirmed); return; } if (!refusedBeforeEffect(error)) { console.error('Subscription update failed with an unproven outcome:', error); setCommitError(SENT_FAILED_UNSURE); return; } // A FAILED PURCHASE MUST SAY SO AND KEEP THE DRAFT. Clearing it would leave an admin // unsure whether they had been charged, which is the one thing this screen cannot do. console.error('Subscription update failed:', error); // "Please try again" WAS THE ONLY THING THIS SCREEN COULD SAY, and it is wrong for a // deliberate refusal - the server answers 409 when an add-on is the last paid line on the // subscription, and retrying will never clear that. The way out lives in the server's own // sentence, and this threw it away. // The reassurance moves into the CONTEXT rather than being replaced, so every branch still // carries it: a billing screen that says "we could not" without "nothing was charged" is the // one thing an admin cannot be left wondering about. setCommitError(describeServerRefusal(error, 'We could not complete that change, and nothing was charged')); } finally { window.clearTimeout(timer); if (checkoutAbort.current === controller) checkoutAbort.current = null; setCommittingOpening((current) => (current === opening.seq ? null : current)); } }; // THIS BUTTON WAS DEAD. It POSTed to `/Stripe/portal`, a route that exists nowhere - no SAM // function, no entry in routes.ts, no handler. API Gateway answers an unmatched route with a 403, so // it failed in the quiet way: a spinner, a console line, and no portal. The real route is // `GET /R4/Stripe/customer-portal`, which is what the grace-period banner has always called. // No `customerId` is sent: the server now resolves the Stripe customer from the caller's own // organization, because taking it from the URL let one practice open another practice's portal. const handleManagePayment = async () => { setPortalLoading(true); setPortalError(null); try { const returnUrl = `${window.location.origin}${window.location.pathname}#/dashboard/subscription`; const response = await get<{ url?: string }>( `${environment.apiUrl}/Stripe/customer-portal?returnUrl=${encodeForStripe(returnUrl)}`, ); // An answer with no url is a failure too, and it used to end exactly like success: the // spinner stopped and the page sat there. if (response.url) { window.location.href = response.url; return; } setPortalError('We could not open your payment details just now. Nothing has changed.'); } catch (error) { console.error('Could not open the payment portal:', error); setPortalError('We could not open your payment details just now. Nothing has changed.'); } finally { setPortalLoading(false); } }; const isFreeBasic = billingMode === 'free_basic'; // The section is hidden ONLY when we know there is nothing to say AND the practice does not pay // us. Gating it on `!isFreeBasic` alone hides last year's receipts from a practice that downgraded, // and hides the "we could not load them" line from everybody - which is the state most worth seeing. const showReceipts = !isFreeBasic || receipts === null || (receipts?.length ?? 0) > 0 || receiptsStatus === 'unreadable' || receiptsStatus === 'refused'; // A list kept from an earlier read is said to be out of date once a later read fails or is refused. const receiptsStale = receipts !== undefined && receipts !== null && (receiptsStatus === 'unreadable' || receiptsStatus === 'refused'); return (
{isLoading ? ( ) : ( // NOT the ProfileLayoutContainer this page used to sit in (a modal-sized box in a wide // viewport). The admin deck runs the cards across the page body from the breadcrumb line.
{/* Every page title is the shared sage h1, this one included (common-ui-standard 3.1). */}

Subscription

{/* ── ① THE BILL LEADS (decision 2) ───────────────────────────────────────── The single most important fact about a subscription, and it was absent from the subscription page. */}
{subscription === null && !billingChecked ? ( // NOT ASKED YET IS NOT A FAILURE, and until 2026-09-14 this told a // paying practice their billing could not be read while the request // that would read it had not been made. `null` is both "we asked and // could not" and "nobody has asked"; `billingChecked` is what tells // them apart, and only the first deserves the sentence below. ) : subscription === null ? ( // We could not read it, so we say nothing. "$0/mo" to a paying practice // is a false statement about their own bill.

We couldn’t load your billing details just now. Nothing has changed.

) : (
{formatMoney(subscription.monthlyTotalCents)} /month
{/* ⚠️ On Free Basic this states what is INCLUDED. ⛔ Not an upgrade pitch — a page whose first message to a non-paying customer is "buy something" is a page they stop opening. */} {/* No next-charge date while a payment has failed: the notice below says what happened and by when, and a promised date beside it reads as a contradiction. */}

{isFreeBasic ? 'Free Basic — appointments, messaging, self-booking and questionnaires are included, unlimited.' : subscriptionNotice ? null : subscription.cancelAtPeriodEnd ? subscription.paidThrough ? `Paid through ${chargeDateLabel(subscription.paidThrough, subscription.paidThroughInstant, practiceZone)}; it will not renew.` : 'Cancelled; it will not renew.' : subscription.nextChargeAt ? `Next charge ${chargeDateLabel(subscription.nextChargeAt, subscription.nextChargeInstant, practiceZone)}` : 'Next charge date unavailable.'}

{billingMode !== 'contractor_sponsored' && subscription.paymentMethod && ( {/* 💳 Card 535 — the server names the method. A Link or bank payment has no brand and no last four, and composing "{brand} ending {last4}" from them rendered an empty heading to a practice that had just paid. */} {subscription.paymentMethod.label} )} {!isFreeBasic && billingMode !== 'contractor_sponsored' && ( )} {portalError && (

{portalError}

)}
)} {subscriptionNotice && (

{subscriptionNotice}

)} {/* ── 💸 CARD 554 ITEM 2 — YOU ARE PAYING FOR MORE THAN ONE SUBSCRIPTION ── A practice carried two active subscriptions for a day and the second one billed with nothing pointing at it. It was found because a human opened the Stripe dashboard — the server has detected it since, in an audit row and a log line, and neither is something the practice can see. They are the ones the money leaves. ⛔ NO BUTTON, AND THAT IS THE FEATURE. Which subscription is "the real one" is a judgement about intent, and cancelling the wrong one destroys billing history the practice may need. Report; let a person act. ⛔ AND NOTHING RENDERS WHEN THIS IS NULL. Null means we could not ask Stripe — the same reason the bill above says nothing rather than $0. */} {otherActiveSubscriptions && otherActiveSubscriptions.length > 0 && (

{otherActiveSubscriptions.length === 1 ? 'You have a second active subscription.' : `You have ${otherActiveSubscriptions.length + 1} active subscriptions.`}

{/* Says what it COSTS them, not what happened to our data. The total above is one subscription's — that is the surprising part and it has to be said out loud. */} You are being charged for each one, and the amount above covers only the one this practice is set up with. Please contact us and we’ll sort out which to keep — we won’t cancel anything without asking you.

)}
{/* ── ② THE ONE EDITABLE LIST (decisions 1, 3, 6) ─────────────────────────── What you have AND what you could add, in the ruled order. A total with no breakdown is a number you have to trust; a breakdown is one you can check. */}

What you’re paying for

Adjust anything here — nothing is charged until you review and pay.

{rows.map((row) => ( ))} {rows.length === 0 && (

We couldn’t load what’s available to you just now.

)}
{/* ── ⑤ RECEIPTS LIVE IN THE PRODUCT (decision 11) ────────────────────────── 🔻 THIS SECTION USED TO BE A LINK OUT, which is the one thing the design forbids: Stripe's portal keeps the payment method and cancellation — the right home for a payment instrument — but "go and look in the portal" must not be the only door to a receipt. It is a different brand and a different login, and the button was pointing at a route that did not exist anyway. ⛔ Hidden only when we KNOW there is nothing to show and the practice does not pay us. A downgraded practice still has last year's receipts, and a failed read still has something to say. */} {showReceipts && (

Receipts

{receipts === undefined && (

Loading your receipts…

)} {/* 🚨 "We could not find out" — ⛔ never "you have no receipts". Those are different statements about money, and only one is true here. */} {receipts === null && (

{receiptsStatus === 'refused' ? 'Your account is not allowed to read this practice’s receipts.' : 'We couldn’t load your receipts just now. Nothing has changed.'}

)} {receiptsStale && (

{receiptsStatus === 'refused' ? 'Your account is not allowed to read this practice’s receipts, so this list may be out of date.' : 'We couldn’t refresh your receipts just now, so this list may be out of date.'}

)} {receipts?.length === 0 && !receiptsStale && (

No receipts yet — your first one appears after your first payment.

)} {receipts && receipts.length > 0 && ( <>
    {receipts.map((receipt) => { const described = describeReceipt(receipt); return (
  • {described.label} {/* ⭐ Only exceptions earn a word — a "Paid" chip on every row trains the reader to skip the one place an unpaid receipt would appear. */} {described.statusLabel && ( {described.statusLabel} )} {described.dateLabel && (

    {described.dateLabel}

    )}
    {/* ⚠️ Rendered only when known. An amount we could not read must never appear as $0.00. */} {described.amountLabel && ( {described.amountLabel} )} {/* The row survives a missing document — that the practice was billed is the fact worth keeping. */} {described.documentUrl && ( View )}
  • ); })}
{/* ⛔ A capped list that does not say it is capped reads as the whole billing history. */} {moreReceipts && (

Showing your most recent receipts. Older ones are in your billing portal.

)} )}
)} {/* ⛔ NOT RENDERED, AND DELIBERATELY: the raw Stripe subscription and customer ids that were this page's entire "Billing Details" card. Those are OUR support identifiers; a practice needs a bill. */}
)} {/* A paid purchase keeps its recovery here, with or without the dialog, whatever the draft holds. Never "you are on the paid plan" before our own state agrees. */} {changeNote && (

{changeNote}

)} {foundingStanding && !changeNote && (

{foundingSentence(foundingStanding)}

)} {(recovery.phase === 'confirming' || recovery.phase === 'slow') && (
{recovery.phase === 'confirming' ? (

{'Payment received \u2014 activating your plan\u2026'}

) : ( <>

{slowSettlementMessage(recovery.check)}

{receiptsNoteMessage(recovery.receiptsCheck) && (

{receiptsNoteMessage(recovery.receiptsCheck)}

)} { void recovery.checkAgain(); }} className="btn btn-blue-ghost" /> )}
)} {/* ── THE STICKY BAR — THE DELTA IS THE MESSAGE (decision 8) ──────────────────────── ⛔ Never a bare new total: that makes an admin do the subtraction to find out what they just agreed to. */} {hasChanges && (
{/* THE PRICE IS WITHHELD RATHER THAN GUESSED WHILE THE COUNT IS SETTLING. A per-clinician line is priced from a clinician count derived server side, and for a minute or two after a seat change that count is still the old one. Stating a figure here would be asserting a price we know may be about to change - and the checkout, which reads the count fresh, would then refuse rather than charge it. Saying so is the same rule the rest of this product follows for a number it cannot yet stand behind. */} {priceIsSettling ? ( <>
Working out the new price…

A clinician was added or removed. Per-clinician prices are updating and should settle in a moment.

) : ( <>
{delta.deltaCents >= 0 ? '+' : '−'}{formatMoney(Math.abs(delta.deltaCents))}/mo {/* NOT added into the delta, and worded so it cannot be read as part of it: the carriers' fee is billed on its own invoice. */} {delta.oneTimeCents > 0 && ( {formatMoney(delta.oneTimeCents)} one-time, on its own invoice )}

New total {formatMoney(delta.newTotalCents)}/mo {subscription?.nextChargeAt ? `, next charge ${chargeDateLabel(subscription.nextChargeAt, subscription.nextChargeInstant, practiceZone)}` : ''} {' · '} {delta.changedKeys.map((key) => rows.find((r) => r.key === key)?.name).filter(Boolean).join(', ')}

)} {commitError &&

{commitError}

}
)}
{/* 💳 Card 537 — the payment happens here, on this page. ⛔ The modal reports only that Stripe accepted the card; what the practice now HAS is read from our own state by the polling effect above, never from this callback. */} {/* The review the button's own name promises. It states what the footer states - nothing is re-derived here, so the two can never disagree about what is about to be charged. */} {delta.deltaCents >= 0 ? 'Add this to your subscription?' : 'Change your subscription?'} {delta.changedKeys.map((key) => rows.find((r) => r.key === key)?.name).filter(Boolean).join(', ')} {'. '} {delta.deltaCents >= 0 ? `This adds ${formatMoney(Math.abs(delta.deltaCents))} per month` : `This takes ${formatMoney(Math.abs(delta.deltaCents))} per month off`} {delta.oneTimeCents > 0 ? `, and a one-time ${formatMoney(delta.oneTimeCents)} carrier registration charged on its own invoice when SMS is turned on, unless your current registration is already paid for` : ''} {`, taking your subscription to ${formatMoney(delta.newTotalCents)} per month`} {subscription?.nextChargeAt ? `, next charged ${chargeDateLabel(subscription.nextChargeAt, subscription.nextChargeInstant, practiceZone)}` : ''} {'.'} Don't change it {/* NAMES THE MONEY, because "Confirm" beside a number is not the same as agreeing to pay it. A decrease is not a charge and must not say it is. */} {/* Green is the house colour for an action button; blue opens things. With no class this fell through to the default blue, so the one button on the page that commits money looked like a button that opens a window. */} {/* A possible one-time fee is named too, as "any": the review says when it is not owed. */} {delta.deltaCents > 0 ? `Pay ${formatMoney(Math.abs(delta.deltaCents))} a month${delta.oneTimeCents > 0 ? ' and any registration fee' : ''}` : 'Update my subscription'} { void recovery.checkAgain(); }} onClose={() => { setCheckoutOutcome(null); setCheckoutSecret(null); }} /> )} onPaymentComplete={(secret) => { if (secret === checkoutSecret && recovery.paymentComplete(secret)) setCheckoutOutcome({ phase: 'confirming' }); }} onDismiss={(secret) => { // Dismissing is not a failure and must not read as one - nothing was charged, and // the DRAFT IS KEPT so the admin can reopen and pay without rebuilding their change. if (secret !== checkoutSecret) return; recovery.dismiss(secret); setCheckoutOutcome(null); setCheckoutSecret(null); }} />
); }; export default SubscriptionPage;