/** * Add Payment Method Modal Component * * Uses Stripe Elements with SetupIntent to securely save card details * without charging the customer. * * For Stripe Connect, we must initialize Stripe with the connected account ID * so the SetupIntent (created on the connected account) can be confirmed. */ import React, { useState, useEffect } from 'react'; import { loadStripe, Stripe } from '@stripe/stripe-js'; import { Elements, CardElement, useStripe, useElements, } from '@stripe/react-stripe-js'; import { CreditCard, Loader2, X, CheckCircle, AlertCircle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useCreateSetupIntent, useSetDefaultPaymentMethod, useCustomerPaymentMethods } from '../hooks/useCustomerBilling'; import { useQueryClient } from '@tanstack/react-query'; // Cache for Stripe instances per connected account // Note: Module-level cache persists across component re-renders but not page reloads const stripeInstanceCache: Record> = {}; // Clear cache entry (useful for debugging) export const clearStripeCache = (key?: string) => { if (key) { delete stripeInstanceCache[key]; } else { Object.keys(stripeInstanceCache).forEach(k => delete stripeInstanceCache[k]); } }; // Get or create Stripe instance for a connected account (or platform account if empty) // For direct_api mode, customPublishableKey will be the tenant's key // For connect mode, we use the platform's key with stripeAccount const getStripeInstance = ( stripeAccount: string, customPublishableKey?: string ): Promise => { // Use custom key for direct_api mode, platform key for connect mode const publishableKey = customPublishableKey || import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''; // Use 'platform' as cache key for direct_api mode (empty stripeAccount) // For direct_api with custom key, include key in cache to avoid conflicts const cacheKey = customPublishableKey ? `direct_${customPublishableKey.substring(0, 20)}` : (stripeAccount || 'platform'); console.log('[AddPaymentMethodModal] getStripeInstance called with:', { stripeAccount: stripeAccount || '(empty - direct_api mode)', cacheKey, publishableKey: publishableKey.substring(0, 20) + '...', isDirectApi: !!customPublishableKey, }); if (!stripeInstanceCache[cacheKey]) { console.log('[AddPaymentMethodModal] Creating new Stripe instance for:', cacheKey); // Only pass stripeAccount option if it's not empty (connect mode) // For direct_api mode, we use the tenant's own API keys (no connected account needed) stripeInstanceCache[cacheKey] = stripeAccount ? loadStripe(publishableKey, { stripeAccount }) : loadStripe(publishableKey); } else { console.log('[AddPaymentMethodModal] Using cached Stripe instance for:', cacheKey); } return stripeInstanceCache[cacheKey]; }; interface CardFormProps { clientSecret: string; onSuccess: () => void; onCancel: () => void; } const CardFormInner: React.FC = ({ clientSecret, onSuccess, onCancel, }) => { const { t } = useTranslation(); const stripe = useStripe(); const elements = useElements(); const queryClient = useQueryClient(); const [isProcessing, setIsProcessing] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [isComplete, setIsComplete] = useState(false); const [cardComplete, setCardComplete] = useState(false); // Get current payment methods to check if this is the first one const { data: paymentMethodsData } = useCustomerPaymentMethods(); const setDefaultPaymentMethod = useSetDefaultPaymentMethod(); // Detect dark mode for Stripe CardElement styling const [isDarkMode, setIsDarkMode] = useState(() => document.documentElement.classList.contains('dark') ); useEffect(() => { // Watch for dark mode changes const observer = new MutationObserver(() => { setIsDarkMode(document.documentElement.classList.contains('dark')); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }); return () => observer.disconnect(); }, []); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!stripe || !elements) { return; } const cardElement = elements.getElement(CardElement); if (!cardElement) { return; } setIsProcessing(true); setErrorMessage(null); try { // Confirm the SetupIntent with Stripe const { error, setupIntent } = await stripe.confirmCardSetup(clientSecret, { payment_method: { card: cardElement, }, }); if (error) { setErrorMessage(error.message || t('billing.addCardFailed', 'Failed to add card. Please try again.')); setIsProcessing(false); return; } if (setupIntent && setupIntent.status === 'succeeded') { // Get the payment method ID from the setup intent const paymentMethodId = typeof setupIntent.payment_method === 'string' ? setupIntent.payment_method : setupIntent.payment_method?.id; // Check if there's already a default payment method const existingMethods = paymentMethodsData?.payment_methods; const hasDefaultMethod = existingMethods?.some(pm => pm.is_default) ?? false; console.log('[AddPaymentMethodModal] SetupIntent succeeded:', { paymentMethodId, existingMethodsCount: existingMethods?.length ?? 0, hasDefaultMethod, }); // Set as default if no default payment method exists yet if (!hasDefaultMethod && paymentMethodId) { console.log('[AddPaymentMethodModal] No default payment method exists, setting new one as default:', paymentMethodId); // Set as default (fire and forget - don't block the success flow) setDefaultPaymentMethod.mutate(paymentMethodId, { onSuccess: () => { console.log('[AddPaymentMethodModal] Successfully set payment method as default'); }, onError: (err) => { console.error('[AddPaymentMethodModal] Failed to set default payment method:', err); }, }); } else { console.log('[AddPaymentMethodModal] Default already exists or no paymentMethodId - existingMethods:', existingMethods?.length, 'hasDefaultMethod:', hasDefaultMethod, 'paymentMethodId:', paymentMethodId); } // Invalidate payment methods to refresh the list queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] }); setIsComplete(true); setTimeout(() => { onSuccess(); }, 1500); } } catch (err: any) { setErrorMessage(err.message || t('billing.unexpectedError', 'An unexpected error occurred.')); setIsProcessing(false); } }; if (isComplete) { return (

{t('billing.cardAdded', 'Card Added Successfully!')}

{t('billing.cardAddedDescription', 'Your payment method has been saved.')}

); } return (
setCardComplete(e.complete)} />
{errorMessage && (

{errorMessage}

)}

{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}

); }; interface AddPaymentMethodModalProps { isOpen: boolean; onClose: () => void; onSuccess?: () => void; } export const AddPaymentMethodModal: React.FC = ({ isOpen, onClose, onSuccess, }) => { const { t } = useTranslation(); const [clientSecret, setClientSecret] = useState(null); const [stripeAccount, setStripeAccount] = useState(null); const [stripePromise, setStripePromise] = useState | null>(null); const [error, setError] = useState(null); const createSetupIntent = useCreateSetupIntent(); // Detect dark mode for Stripe Elements appearance const [isDarkMode, setIsDarkMode] = useState(() => document.documentElement.classList.contains('dark') ); useEffect(() => { // Watch for dark mode changes const observer = new MutationObserver(() => { setIsDarkMode(document.documentElement.classList.contains('dark')); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }); return () => observer.disconnect(); }, []); useEffect(() => { if (isOpen && !clientSecret && !createSetupIntent.isPending) { // Create SetupIntent when modal opens createSetupIntent.mutate(undefined, { onSuccess: (data) => { console.log('[AddPaymentMethodModal] SetupIntent response:', { client_secret: data.client_secret?.substring(0, 30) + '...', setup_intent_id: data.setup_intent_id, customer_id: data.customer_id, stripe_account: data.stripe_account, publishable_key: data.publishable_key ? data.publishable_key.substring(0, 20) + '...' : null, }); // stripe_account can be empty string for direct_api mode, or acct_xxx for connect mode // Only undefined/null indicates an error if (data.stripe_account === undefined || data.stripe_account === null) { console.error('[AddPaymentMethodModal] stripe_account is undefined/null - payment system may not be configured correctly'); setError(t('billing.paymentSystemNotConfigured', 'The payment system is not fully configured. Please contact support.')); return; } setClientSecret(data.client_secret); setStripeAccount(data.stripe_account); // Load Stripe - empty stripe_account means direct_api mode (use tenant's publishable_key) // Non-empty stripe_account means connect mode (use platform key with connected account) setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key)); }, onError: (err: any) => { console.error('[AddPaymentMethodModal] SetupIntent error:', err); setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.')); }, }); } }, [isOpen]); useEffect(() => { if (!isOpen) { // Reset state when modal closes setClientSecret(null); setStripeAccount(null); setStripePromise(null); setError(null); } }, [isOpen]); const handleSuccess = () => { onSuccess?.(); onClose(); }; if (!isOpen) return null; return (

{t('billing.addPaymentMethod', 'Add Payment Method')}

{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}

{createSetupIntent.isPending ? (

{t('common.loading', 'Loading...')}

) : error ? (

{error}

) : clientSecret && stripePromise ? ( ) : null}
); }; export default AddPaymentMethodModal;