import React, { useEffect, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '../ui/dialog'; import { Button } from '../ui/button'; import { emitBillingPaymentComplete, useBillingContext } from '../../services/BillingContext.tsx'; import { useAuthServiceContext, useHttpService } from '../../services'; import { foundingSentence, startAddOnCheckout } from '../../lib/billingCheckout.ts'; import { StripeCheckoutModal } from '../../pages/SubscriptionPage/StripeCheckoutModal'; import { chargeDateLabel } from '../../pages/SubscriptionPage/chargeDate'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { formatMoney } from '../../utils/money'; import { practiceTimeZoneOf } from '../../utils/practiceTimezone'; // - no `appointmentBlocks` label. The server no longer raises a billing-limit error naming // that upgrade target (the whole gate is gone), so the entry could only ever have been reached by a // stale response - and reaching it would have offered to sell a retired SKU at a price we no longer // charge. The modal already renders nothing for an unknown target. // - no `extraSeats` label either, and for the stronger of the two reasons: the $35/mo // staff seat is not merely retired, the dimension is FREE now. Offering it would charge for something we // give away. // - no `extraQuestionnaires` label either, same reasoning as seats: a paid plan has unlimited // questionnaires, so offering to sell one would charge for something that is now free. Free Basic still // has an allowance, and hitting it should prompt an UPGRADE, never a per-questionnaire purchase. // - the telehealth cap. Without an entry here the label falls back to the RAW TARGET KEY, so // a practice that has used its 20 free video visits is offered *"Add: telehealthUnlimited"* - which is an // internal identifier, not a price, on the one screen whose whole job is asking someone to spend money. // $10/clinician/mo, org-wide, from the settled pricing model. const UPGRADE_TARGET_LABEL: Record = { extraStorageBlocks: 'Storage Block (+10 GB) — $10/mo', // The seat, which reaches this modal from the Staff screen rather than from a usage cap: the // server refuses a chargeable seat on a practice with no subscription, and this is the door to // paying for it. Without an entry the label falls back to the raw key, so an administrator would // be offered "Add: clinicianSeats" on the screen whose whole job is asking them to spend money. clinicianSeats: 'Clinician seat — $50/clinician/mo, first one free', telehealthUnlimited: 'Unlimited video visits — $10/clinician/mo', }; const PURCHASED_ITEM: Record = { clinicianSeats: 'Clinician seat', telehealthUnlimited: 'Unlimited video visits', extraStorageBlocks: 'Storage block (+10 GB)', }; /** * What was just bought. The item and its price are what the admin agreed to; the card and the new total * are only stated once a billing read reflects THIS purchase. A cached read, or one taken before the * Stripe webhook landed, still describes the bill as it was. */ export type PurchaseSettlement = 'pending' | 'slow' | 'confirmed'; const PurchaseConfirmation = ({ item, priceCents, settlement, paymentMethod, monthlyTotalCents, nextChargeAt, nextChargeInstant, timeZone }: { item: string; priceCents?: number; settlement: PurchaseSettlement; paymentMethod?: string; monthlyTotalCents?: number; nextChargeAt?: string | null; nextChargeInstant?: string | null; timeZone?: string; }) => (

{settlement === 'confirmed' ? `${item} added` : 'Payment received'}

{item}{priceCents !== undefined && <>: {formatMoney(priceCents)}/month}

{settlement === 'confirmed' ? ( <> {paymentMethod &&

Charged to {paymentMethod}

} {monthlyTotalCents !== undefined && (

New monthly total: {formatMoney(monthlyTotalCents)} {nextChargeAt ? <>, next charge {chargeDateLabel(nextChargeAt, nextChargeInstant, timeZone)} : null}

)} ) : settlement === 'slow' ? (

Your payment went through. The details are taking longer than usual and will appear on the Subscription page.

) : (

Your payment went through. Loading the details…

)}
); /** Has the billing read caught up with a purchase that started from `previousTotalCents`? */ export const purchaseIsReflected = ( subscription: { monthlyTotalCents: number; paymentMethod: unknown } | null | undefined, previousTotalCents: number, priceCents: number | undefined, ): boolean => Boolean(subscription?.paymentMethod) && (subscription?.monthlyTotalCents ?? 0) >= previousTotalCents + (priceCents ?? 1); const BillingLimitModal: React.FC = () => { const { billingLimitError, setBillingLimitError, refreshBilling, addOnPricing, subscription } = useBillingContext(); const { userRole, USER_ROLES, currentOrganization } = useAuthServiceContext(); const { post } = useHttpService(); const [isUpgrading, setIsUpgrading] = useState(false); const [checkoutSecret, setCheckoutSecret] = useState(null); const [paymentComplete, setPaymentComplete] = useState(false); // The bill before this checkout, and whether a read since has shown the purchase on it. const previousTotalRef = useRef(0); const [settlement, setSettlement] = useState('pending'); const refreshRef = useRef(refreshBilling); refreshRef.current = refreshBilling; const [checkoutError, setCheckoutError] = useState(null); const [foundingNote, setFoundingNote] = useState(null); const requestGeneration = useRef(0); //: the limit named something this build cannot sell. Held separately from the error state // so dismissing the modal clears it, and so it can never be confused with a failed request. const [unavailable, setUnavailable] = useState(false); // A seat arrives here only after its own confirmation has named the price and said "Continue to // payment", so asking again before the card form is a second question with the same answer. const [openingCheckout, setOpeningCheckout] = useState(false); const upgradeRef = useRef<() => Promise>(); useEffect(() => { setCheckoutSecret(null); setCheckoutError(null); setFoundingNote(null); setPaymentComplete(false); setSettlement('pending'); setUnavailable(false); setIsUpgrading(false); setOpeningCheckout(false); return () => { requestGeneration.current += 1; }; }, [billingLimitError, currentOrganization?.id]); const seatGoesStraightToCheckout = billingLimitError?.upgradeTarget === 'clinicianSeats' && userRole === USER_ROLES.ADMIN; useEffect(() => { if (!seatGoesStraightToCheckout) return; setOpeningCheckout(true); void upgradeRef.current?.().finally(() => setOpeningCheckout(false)); }, [billingLimitError, seatGoesStraightToCheckout]); const priceForTarget = billingLimitError ? (addOnPricing?.unitPriceCents as Record | undefined)?.[billingLimitError.upgradeTarget] : undefined; useEffect(() => { if (!paymentComplete || settlement === 'confirmed') return; if (!purchaseIsReflected(subscription, previousTotalRef.current, priceForTarget)) return; setSettlement('confirmed'); setCheckoutError(null); }, [paymentComplete, settlement, subscription, priceForTarget]); // Reads again until the bill shows the purchase. A failed read is not a failed payment, so it is // retried; after two minutes it stops and says the details will follow, never that it failed. useEffect(() => { if (!paymentComplete || settlement !== 'pending') return; let stopped = false; let timer = 0; const startedAt = Date.now(); const tick = async () => { if (stopped) return; try { await refreshRef.current(); } catch { /* retried */ } if (stopped) return; if (Date.now() - startedAt > 120_000) { setSettlement('slow'); return; } timer = window.setTimeout(() => { void tick(); }, 2_500); }; timer = window.setTimeout(() => { void tick(); }, 2_500); return () => { stopped = true; window.clearTimeout(timer); }; }, [paymentComplete, settlement]); if (!billingLimitError) { return null; } const isAdmin = userRole === USER_ROLES.ADMIN; const purchasedItem = PURCHASED_ITEM[billingLimitError.upgradeTarget] ?? billingLimitError.upgradeTarget; const purchasedPriceCents = priceForTarget; const upgradeLabel = UPGRADE_TARGET_LABEL[billingLimitError.upgradeTarget] ?? billingLimitError.upgradeTarget; const title = billingLimitError.limitType.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()); const handleUpgrade = async () => { const generation = requestGeneration.current; // The purchase is for the practice the admin was in when they asked; a switch retires it before it is sent. const organizationId = currentOrganization?.id; if (!organizationId) { setCheckoutError('Choose your practice before upgrading. Nothing was charged.'); return; } const pinnedPost = (url: string, data: unknown) => post(url, data, { organizationId, admit: () => { if (generation !== requestGeneration.current) throw new Error('This upgrade was for another practice view. Nothing was charged.'); }, }); setIsUpgrading(true); setUnavailable(false); setCheckoutError(null); setFoundingNote(null); try { // - the prices are passed so the no-Price refusal is LIVE here rather than // theoretical. This modal is the door that bypasses the subscription page, which is the only // other place that knows a capability has no Stripe Price. const result = await startAddOnCheckout( pinnedPost, billingLimitError.upgradeTarget, addOnPricing?.unitPriceCents, ); if (generation !== requestGeneration.current) return; if (result.clientSecret) { setCheckoutSecret(result.clientSecret); return; } // NAMED CONTROL: a limit for a capability this build cannot buy must SAY SO, // and must not open a checkout. Before this, an unresolved target posted an all-zero body, // the server cheerfully did nothing, and the modal simply stopped - indistinguishable from // a cancelled checkout. The practice believes it paid. That is a refund conversation, // not a bug report, which is why silence is the one outcome not allowed here. if (result.unknownTarget) { setUnavailable(true); return; } if (result.founding) { setFoundingNote(foundingSentence(result.founding)); if (result.founding === 'provider-complete') await refreshBilling(); return; } if (!result.redirected && result.updated) { setBillingLimitError(null); await refreshBilling(); } } catch (error) { if (generation === requestGeneration.current) { setCheckoutError(relayServerFailure(error, 'We could not open the payment form. Please try again.')); } } finally { if (generation === requestGeneration.current) setIsUpgrading(false); } }; upgradeRef.current = handleUpgrade; const handleDismiss = () => { requestGeneration.current += 1; setCheckoutSecret(null); setBillingLimitError(null); }; const handlePaymentComplete = async () => { const generation = requestGeneration.current; setCheckoutSecret(null); previousTotalRef.current = subscription?.monthlyTotalCents ?? 0; setSettlement('pending'); setPaymentComplete(true); // Told at once, before any refresh: the waiting save checks our own billing itself, and a dismiss // while the refresh is out must not lose a payment that has already happened. emitBillingPaymentComplete(billingLimitError?.upgradeTarget ?? ''); try { await refreshBilling(); } catch { if (generation === requestGeneration.current) { setCheckoutError('Your payment completed, but we could not refresh your subscription. Check its status before trying again.'); } } }; if (checkoutSecret) { return { void handlePaymentComplete(); }} onDismiss={() => setCheckoutSecret(null)} />; } // Controlled open: dismiss on any close intent (Escape, outside-click, X, Dismiss). // Rendering via the app's Radix dialog keeps this modal on the top layer so it stays // interactive even when a Radix dialog (e.g. the receptionist booking flow) is open beneath it. const handleOpenChange = (open: boolean) => { if (!open) { handleDismiss(); } }; return ( {title} {!paymentComplete && {billingLimitError.diagnostics}} {checkoutError &&

{checkoutError}

} {foundingNote &&

{foundingNote}

} {paymentComplete ? ( ) : openingCheckout && !checkoutError ? (

Opening the payment form…

) : unavailable ? ( // States plainly that NOTHING was charged. A practice that pressed Upgrade and saw // the dialog go quiet has no way to know whether a card was hit.

{upgradeLabel} can’t be purchased from here yet, so no charge was made. Please contact support and we’ll switch it on for you.

) : isAdmin ? (

Add: {upgradeLabel}

) : (

Contact your administrator to upgrade the plan.

)} {/* ⛔ The Upgrade button GOES once we know it cannot complete — leaving it is a dead control that invites a second click on a purchase that already refused. */} {isAdmin && !unavailable && !paymentComplete && !(openingCheckout && !checkoutError) && ( )}
); }; export default BillingLimitModal;