import React, { useEffect, useRef, useState } from 'react'; import { loadStripe, type Stripe, type StripeEmbeddedCheckout } from '@stripe/stripe-js'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../components/ui/dialog'; import { environment } from '../../environments/environment'; import { publishableKeyForStack } from '../../utils/stripePublishableKey'; /** * - PAY WITHOUT LEAVING THE PRODUCT. * * Spencer, 2026-08-19: *"would the better ui not be to load the payment window in a modal and then just * show a success/fail once done instead of a browser redirect?"* * * Stripe's Embedded Checkout mounts the payment form inside an iframe Stripe owns, on our page. The admin * reviews the change, pays, and sees the result on one screen. * * THE ONE THING THIS COMPONENT MUST NOT DO * * **It must never tell anyone they are on the paid plan.** `onComplete` means *Stripe accepted the * payment*, which is a different fact from *our system reflects it* - and the gap is measurable. * On the payment that motivated this card, Stripe accepted the card at **02:23:18Z** and our * BillingProfile did not reflect it until **02:24:23Z** - **65 seconds later**. A modal that closed * announcing "you're on the paid plan" would have been wrong for over a minute. * And on that same payment the subscription linked while the ENTITLEMENT never turned on, so a * client-side success message would have put a green tick over a capability the practice did not have. * This component reports only that the payment completed. **Deciding what the practice now HAS is the * caller's job, from our own state**. * * PCI SCOPE * The card fields live in a **Stripe-owned iframe**, exactly as they do on the hosted redirect, so this * stays SAQ A. That holds only while we never render a card input ourselves - if this ever drifts * toward a custom form, the compliance answer changes. */ interface Props { /** From `POST /Stripe/checkout` with `embedded: true`. Opens the modal when set. */ clientSecret: string | null; /** * Stripe accepted the payment. NOT "the practice is now on the paid plan" - see the header. The * caller is expected to go and read our own state. */ onPaymentComplete: (clientSecret: string) => void; /** The admin dismissed the modal for this checkout. */ onDismiss: (clientSecret: string) => void; /** Once paid, what the caller confirmed from its own state; it replaces the payment form. */ outcome?: React.ReactNode; } export const StripeCheckoutModal: React.FC = ({ clientSecret, onPaymentComplete, onDismiss, outcome }) => { const hostRef = useRef(null); const checkoutRef = useRef(null); const [error, setError] = useState(null); // HELD IN A REF AND READ INSIDE THE EFFECT. Depending on the callback directly would re-run the // effect on every parent render - tearing down and re-mounting Stripe's iframe underneath someone // typing their card number. const completeRef = useRef(onPaymentComplete); completeRef.current = onPaymentComplete; const showingOutcome = Boolean(outcome); useEffect(() => { if (!clientSecret || showingOutcome) return; let cancelled = false; setError(null); let instance: StripeEmbeddedCheckout | null = null; void (async () => { try { const publishableKey = publishableKeyForStack(environment.stripePublicKey, environment.production); if (!publishableKey) throw new Error('The Stripe publishable key does not match this stack'); const stripe: Stripe | null = await loadStripe(publishableKey); if (!stripe) throw new Error('Stripe.js did not load'); if (cancelled) return; instance = await stripe.initEmbeddedCheckout({ clientSecret, // Each payment form reports for its own checkout, and not once it has been torn down. onComplete: () => { if (!cancelled) completeRef.current(clientSecret); }, }); // The dialog may have closed while Stripe was loading. Mounting into a detached node // leaves an orphaned iframe that keeps running. if (cancelled || !hostRef.current) { instance.destroy(); return; } checkoutRef.current = instance; instance.mount(hostRef.current); } catch (err) { console.error('Could not start embedded checkout:', err); if (!cancelled) { // Never a bare "something went wrong". Nothing has been charged at this point and // saying so is the only thing an admin actually needs to know. setError('We could not open the payment form. Nothing has been charged.'); } } })(); return () => { cancelled = true; // `destroy`, not just React unmounting the host node. Stripe's instance owns an iframe and // listeners outside React's tree; dropping the reference leaks both. try { (checkoutRef.current ?? instance)?.destroy(); } catch { // Destroying an already-destroyed instance throws. Nothing to recover, nothing to report. } checkoutRef.current = null; }; }, [clientSecret, showingOutcome]); return ( { if (!open && clientSecret) onDismiss(clientSecret); }} > Payment Enter your payment details to complete this subscription change. {outcome ? (
{outcome}
) : error ? (

{error}

) : ( // Stripe sizes its own iframe; a fixed height here would either clip the form or leave // a gap under it as the form grows through its steps.
)}
); };