Files
smoothschedule/frontend/src/components/AddPaymentMethodModal.tsx
poduck b0512a660c feat(billing): Add customer billing page with payment method management
- Add CustomerBilling page for customers to view payment history and manage cards
- Create AddPaymentMethodModal with Stripe Elements for secure card saving
- Support both Stripe Connect and direct API payment modes
- Auto-set first payment method as default when no default exists
- Add dark mode support for Stripe card input styling
- Add customer billing API endpoints for payment history and saved cards
- Add stripe_customer_id field to User model for Stripe customer tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:06:30 -05:00

447 lines
17 KiB
TypeScript

/**
* 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<string, Promise<Stripe | null>> = {};
// 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<Stripe | null> => {
// 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<CardFormProps> = ({
clientSecret,
onSuccess,
onCancel,
}) => {
const { t } = useTranslation();
const stripe = useStripe();
const elements = useElements();
const queryClient = useQueryClient();
const [isProcessing, setIsProcessing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{t('billing.cardAdded', 'Card Added Successfully!')}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{t('billing.cardAddedDescription', 'Your payment method has been saved.')}
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: isDarkMode ? '#f1f5f9' : '#1e293b',
iconColor: isDarkMode ? '#94a3b8' : '#64748b',
'::placeholder': {
color: isDarkMode ? '#64748b' : '#94a3b8',
},
},
invalid: {
color: isDarkMode ? '#f87171' : '#dc2626',
iconColor: isDarkMode ? '#f87171' : '#dc2626',
},
},
}}
onChange={(e) => setCardComplete(e.complete)}
/>
</div>
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{errorMessage}</p>
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="flex-1 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
disabled={!stripe || isProcessing || !cardComplete}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving', 'Saving...')}
</>
) : (
<>
<CreditCard className="w-4 h-4" />
{t('billing.saveCard', 'Save Card')}
</>
)}
</button>
</div>
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mt-2">
{t('billing.stripeSecure', 'Your payment information is securely processed by Stripe')}
</p>
</form>
);
};
interface AddPaymentMethodModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export const AddPaymentMethodModal: React.FC<AddPaymentMethodModalProps> = ({
isOpen,
onClose,
onSuccess,
}) => {
const { t } = useTranslation();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [stripeAccount, setStripeAccount] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('billing.addPaymentMethod', 'Add Payment Method')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('billing.addPaymentMethodDescription', 'Save a card for future payments')}
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{createSetupIntent.isPending ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-brand-600 mb-4" />
<p className="text-gray-600 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div>
) : error ? (
<div className="space-y-4">
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => {
setError(null);
createSetupIntent.mutate(undefined, {
onSuccess: (data) => {
setClientSecret(data.client_secret);
setStripeAccount(data.stripe_account);
setStripePromise(getStripeInstance(data.stripe_account, data.publishable_key));
},
onError: (err: any) => {
setError(err.response?.data?.error || t('billing.setupIntentFailed', 'Failed to initialize. Please try again.'));
},
});
}}
className="flex-1 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('common.tryAgain', 'Try Again')}
</button>
</div>
</div>
) : clientSecret && stripePromise ? (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: isDarkMode ? 'night' : 'stripe',
variables: {
colorPrimary: '#2563eb',
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
colorText: isDarkMode ? '#f1f5f9' : '#1e293b',
colorDanger: isDarkMode ? '#f87171' : '#dc2626',
fontFamily: 'system-ui, -apple-system, sans-serif',
spacingUnit: '12px',
borderRadius: '8px',
},
},
}}
>
<CardFormInner
clientSecret={clientSecret}
onSuccess={handleSuccess}
onCancel={onClose}
/>
</Elements>
) : null}
</div>
</div>
);
};
export default AddPaymentMethodModal;