- 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>
447 lines
17 KiB
TypeScript
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;
|