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>
This commit is contained in:
@@ -45,6 +45,7 @@ const CustomerDashboard = React.lazy(() => import('./pages/customer/CustomerDash
|
|||||||
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
const CustomerSupport = React.lazy(() => import('./pages/customer/CustomerSupport'));
|
||||||
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
const ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
||||||
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
||||||
|
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
|
||||||
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
||||||
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
||||||
|
|
||||||
@@ -537,7 +538,7 @@ const AppContent: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Route path="/" element={<CustomerDashboard />} />
|
<Route path="/" element={<CustomerDashboard />} />
|
||||||
<Route path="/book" element={<BookingPage />} />
|
<Route path="/book" element={<BookingPage />} />
|
||||||
<Route path="/payments" element={<Payments />} />
|
<Route path="/payments" element={<CustomerBilling />} />
|
||||||
<Route path="/support" element={<CustomerSupport />} />
|
<Route path="/support" element={<CustomerSupport />} />
|
||||||
<Route path="/profile" element={<ProfileSettings />} />
|
<Route path="/profile" element={<ProfileSettings />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
|||||||
446
frontend/src/components/AddPaymentMethodModal.tsx
Normal file
446
frontend/src/components/AddPaymentMethodModal.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -70,7 +70,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
|
|||||||
colorDanger: '#df1b41',
|
colorDanger: '#df1b41',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
fontSizeBase: '14px',
|
fontSizeBase: '14px',
|
||||||
spacingUnit: '4px',
|
spacingUnit: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
|||||||
colorText: '#1e293b',
|
colorText: '#1e293b',
|
||||||
colorDanger: '#dc2626',
|
colorDanger: '#dc2626',
|
||||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
spacingUnit: '4px',
|
spacingUnit: '12px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
|||||||
const handleQuickLogin = async (user: TestUser) => {
|
const handleQuickLogin = async (user: TestUser) => {
|
||||||
setLoading(user.email);
|
setLoading(user.email);
|
||||||
try {
|
try {
|
||||||
// Call token auth API - username field contains email since we use email as username
|
// Call custom login API that supports email login
|
||||||
const response = await apiClient.post('/auth-token/', {
|
const response = await apiClient.post('/auth/login/', {
|
||||||
username: user.email,
|
email: user.email,
|
||||||
password: user.password,
|
password: user.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
// Store token in cookie (use 'access_token' to match what client.ts expects)
|
||||||
setCookie('access_token', response.data.token, 7);
|
setCookie('access_token', response.data.access, 7);
|
||||||
|
|
||||||
// Clear any existing masquerade stack - this is a fresh login
|
// Clear any existing masquerade stack - this is a fresh login
|
||||||
localStorage.removeItem('masquerade_stack');
|
localStorage.removeItem('masquerade_stack');
|
||||||
|
|||||||
155
frontend/src/hooks/useCustomerBilling.ts
Normal file
155
frontend/src/hooks/useCustomerBilling.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Customer Billing Hooks
|
||||||
|
*
|
||||||
|
* React Query hooks for fetching customer billing data including
|
||||||
|
* payment history, outstanding payments, and saved payment methods.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface OutstandingPayment {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
service_name: string;
|
||||||
|
amount: number;
|
||||||
|
amount_display: string;
|
||||||
|
status: string;
|
||||||
|
start_time: string | null;
|
||||||
|
end_time: string | null;
|
||||||
|
payment_status: 'pending' | 'unpaid';
|
||||||
|
payment_intent_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentHistoryItem {
|
||||||
|
id: number;
|
||||||
|
event_id: number;
|
||||||
|
event_title: string;
|
||||||
|
service_name: string;
|
||||||
|
amount: number;
|
||||||
|
amount_display: string;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
payment_intent_id: string;
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
event_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingSummary {
|
||||||
|
total_spent: number;
|
||||||
|
total_spent_display: string;
|
||||||
|
total_outstanding: number;
|
||||||
|
total_outstanding_display: string;
|
||||||
|
payment_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerBillingData {
|
||||||
|
outstanding: OutstandingPayment[];
|
||||||
|
payment_history: PaymentHistoryItem[];
|
||||||
|
summary: BillingSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedPaymentMethod {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
brand: string | null;
|
||||||
|
last4: string | null;
|
||||||
|
exp_month: number | null;
|
||||||
|
exp_year: number | null;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerPaymentMethodsData {
|
||||||
|
payment_methods: SavedPaymentMethod[];
|
||||||
|
has_stripe_customer: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch customer billing data (payment history + outstanding payments)
|
||||||
|
*/
|
||||||
|
export const useCustomerBilling = () => {
|
||||||
|
return useQuery<CustomerBillingData>({
|
||||||
|
queryKey: ['customerBilling'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get('/payments/customer/billing/');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch customer's saved payment methods
|
||||||
|
*/
|
||||||
|
export const useCustomerPaymentMethods = () => {
|
||||||
|
return useQuery<CustomerPaymentMethodsData>({
|
||||||
|
queryKey: ['customerPaymentMethods'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get('/payments/customer/payment-methods/');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// SetupIntent response type
|
||||||
|
export interface SetupIntentResponse {
|
||||||
|
client_secret: string;
|
||||||
|
setup_intent_id: string;
|
||||||
|
customer_id: string;
|
||||||
|
stripe_account: string; // Connected account ID for Stripe Connect (empty for direct_api)
|
||||||
|
publishable_key?: string; // Tenant's publishable key for direct_api mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a SetupIntent for adding a new payment method
|
||||||
|
*/
|
||||||
|
export const useCreateSetupIntent = () => {
|
||||||
|
return useMutation<SetupIntentResponse>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await apiClient.post('/payments/customer/setup-intent/');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a payment method
|
||||||
|
*/
|
||||||
|
export const useDeletePaymentMethod = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{ success: boolean; message: string }, Error, string>({
|
||||||
|
mutationFn: async (paymentMethodId: string) => {
|
||||||
|
const { data } = await apiClient.delete(`/payments/customer/payment-methods/${paymentMethodId}/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate payment methods query to refresh the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to set a payment method as default
|
||||||
|
*/
|
||||||
|
export const useSetDefaultPaymentMethod = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<{ success: boolean; message: string }, Error, string>({
|
||||||
|
mutationFn: async (paymentMethodId: string) => {
|
||||||
|
const { data } = await apiClient.post(`/payments/customer/payment-methods/${paymentMethodId}/default/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate payment methods query to refresh the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customerPaymentMethods'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -29,8 +29,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, Business, PaymentMethod, Customer } from '../types';
|
import { User, Business, PaymentMethod } from '../types';
|
||||||
import { CUSTOMERS } from '../mockData';
|
|
||||||
import PaymentSettingsSection from '../components/PaymentSettingsSection';
|
import PaymentSettingsSection from '../components/PaymentSettingsSection';
|
||||||
import TransactionDetailModal from '../components/TransactionDetailModal';
|
import TransactionDetailModal from '../components/TransactionDetailModal';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
@@ -96,43 +95,39 @@ const Payments: React.FC = () => {
|
|||||||
const exportMutation = useExportTransactions();
|
const exportMutation = useExportTransactions();
|
||||||
|
|
||||||
// Customer view state (for customer-facing)
|
// Customer view state (for customer-facing)
|
||||||
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
|
// Initialize with empty payment methods - real data will come from API when implemented
|
||||||
CUSTOMERS.find(c => c.userId === effectiveUser.id)
|
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||||
);
|
|
||||||
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
|
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
|
||||||
|
|
||||||
// Customer handlers
|
// Customer handlers
|
||||||
const handleSetDefault = (pmId: string) => {
|
const handleSetDefault = (pmId: string) => {
|
||||||
if (!customerProfile) return;
|
const updatedMethods = paymentMethods.map(pm => ({
|
||||||
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
|
|
||||||
...pm,
|
...pm,
|
||||||
isDefault: pm.id === pmId
|
isDefault: pm.id === pmId
|
||||||
}));
|
}));
|
||||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
setPaymentMethods(updatedMethods);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMethod = (pmId: string) => {
|
const handleDeleteMethod = (pmId: string) => {
|
||||||
if (!customerProfile) return;
|
|
||||||
if (window.confirm(t('payments.confirmDeletePaymentMethod'))) {
|
if (window.confirm(t('payments.confirmDeletePaymentMethod'))) {
|
||||||
const updatedMethods = customerProfile.paymentMethods.filter(pm => pm.id !== pmId);
|
const updatedMethods = paymentMethods.filter(pm => pm.id !== pmId);
|
||||||
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
|
if (updatedMethods.length > 0 && !updatedMethods.some(pm => pm.isDefault)) {
|
||||||
updatedMethods[0].isDefault = true;
|
updatedMethods[0].isDefault = true;
|
||||||
}
|
}
|
||||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
setPaymentMethods(updatedMethods);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddCard = (e: React.FormEvent) => {
|
const handleAddCard = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!customerProfile) return;
|
|
||||||
const newCard: PaymentMethod = {
|
const newCard: PaymentMethod = {
|
||||||
id: `pm_${Date.now()}`,
|
id: `pm_${Date.now()}`,
|
||||||
brand: 'Visa',
|
brand: 'Visa',
|
||||||
last4: String(Math.floor(1000 + Math.random() * 9000)),
|
last4: String(Math.floor(1000 + Math.random() * 9000)),
|
||||||
isDefault: customerProfile.paymentMethods.length === 0
|
isDefault: paymentMethods.length === 0
|
||||||
};
|
};
|
||||||
const updatedMethods = [...customerProfile.paymentMethods, newCard];
|
const updatedMethods = [...paymentMethods, newCard];
|
||||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
setPaymentMethods(updatedMethods);
|
||||||
setIsAddCardModalOpen(false);
|
setIsAddCardModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -788,7 +783,7 @@ const Payments: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Customer View
|
// Customer View
|
||||||
if (isCustomer && customerProfile) {
|
if (isCustomer) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -805,7 +800,7 @@ const Payments: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{customerProfile.paymentMethods.length > 0 ? customerProfile.paymentMethods.map((pm) => (
|
{paymentMethods.length > 0 ? paymentMethods.map((pm) => (
|
||||||
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<div key={pm.id} className="p-6 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<CreditCard className="text-gray-400" size={24} />
|
<CreditCard className="text-gray-400" size={24} />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
import { User, Business, Service, Customer } from '../../types';
|
import { User, Business, Service } from '../../types';
|
||||||
import { SERVICES, CUSTOMERS } from '../../mockData';
|
import { useServices } from '../../hooks/useServices';
|
||||||
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard } from 'lucide-react';
|
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const BookingPage: React.FC = () => {
|
const BookingPage: React.FC = () => {
|
||||||
const { user, business } = useOutletContext<{ user: User, business: Business }>();
|
const { user, business } = useOutletContext<{ user: User, business: Business }>();
|
||||||
const customer = CUSTOMERS.find(c => c.userId === user.id);
|
// Fetch services from API - backend filters for current tenant
|
||||||
|
const { data: services = [], isLoading: servicesLoading } = useServices();
|
||||||
|
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedService, setSelectedService] = useState<Service | null>(null);
|
const [selectedService, setSelectedService] = useState<Service | null>(null);
|
||||||
@@ -23,10 +24,6 @@ const BookingPage: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const handleSelectService = (service: Service) => {
|
const handleSelectService = (service: Service) => {
|
||||||
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
|
|
||||||
// Handled by the conditional rendering below, but could also be an alert.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedService(service);
|
setSelectedService(service);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
};
|
};
|
||||||
@@ -50,26 +47,25 @@ const BookingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderStepContent = () => {
|
const renderStepContent = () => {
|
||||||
if (business.requirePaymentMethodToBook && (!customer || customer.paymentMethods.length === 0)) {
|
|
||||||
return (
|
|
||||||
<div className="text-center bg-yellow-50 dark:bg-yellow-900/30 p-8 rounded-lg border border-yellow-200 dark:border-yellow-700">
|
|
||||||
<AlertTriangle className="mx-auto text-yellow-500" size={40} />
|
|
||||||
<h3 className="mt-4 text-lg font-bold text-yellow-800 dark:text-yellow-200">Payment Method Required</h3>
|
|
||||||
<p className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
|
||||||
This business requires a payment method on file to book an appointment. Please add a card to your account before proceeding.
|
|
||||||
</p>
|
|
||||||
<Link to="/payments" className="mt-6 inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 font-medium shadow-sm transition-colors">
|
|
||||||
<CreditCard size={16} /> Go to Billing
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 1: // Select Service
|
case 1: // Select Service
|
||||||
|
if (servicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (services.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">No services available for booking at this time.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{SERVICES.map(service => (
|
{services.map(service => (
|
||||||
<button
|
<button
|
||||||
key={service.id}
|
key={service.id}
|
||||||
onClick={() => handleSelectService(service)}
|
onClick={() => handleSelectService(service)}
|
||||||
|
|||||||
418
frontend/src/pages/customer/CustomerBilling.tsx
Normal file
418
frontend/src/pages/customer/CustomerBilling.tsx
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Receipt,
|
||||||
|
History,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
DollarSign,
|
||||||
|
Wallet,
|
||||||
|
RefreshCcw,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Star,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { User, Business } from '../../types';
|
||||||
|
import {
|
||||||
|
useCustomerBilling,
|
||||||
|
useCustomerPaymentMethods,
|
||||||
|
useDeletePaymentMethod,
|
||||||
|
useSetDefaultPaymentMethod,
|
||||||
|
OutstandingPayment,
|
||||||
|
PaymentHistoryItem,
|
||||||
|
} from '../../hooks/useCustomerBilling';
|
||||||
|
import { AddPaymentMethodModal } from '../../components/AddPaymentMethodModal';
|
||||||
|
|
||||||
|
type TabType = 'outstanding' | 'history';
|
||||||
|
|
||||||
|
const CustomerBilling: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useOutletContext<{ user: User; business: Business }>(); // Validate context is available
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('outstanding');
|
||||||
|
const [showAddPaymentModal, setShowAddPaymentModal] = useState(false);
|
||||||
|
const [deletingPaymentMethod, setDeletingPaymentMethod] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch billing data from API
|
||||||
|
const { data: billingData, isLoading: billingLoading, error: billingError } = useCustomerBilling();
|
||||||
|
const { data: paymentMethodsData, isLoading: methodsLoading } = useCustomerPaymentMethods();
|
||||||
|
|
||||||
|
// Mutations for payment method management
|
||||||
|
const deletePaymentMethod = useDeletePaymentMethod();
|
||||||
|
const setDefaultPaymentMethod = useSetDefaultPaymentMethod();
|
||||||
|
|
||||||
|
const handleDeletePaymentMethod = async (paymentMethodId: string) => {
|
||||||
|
if (deletingPaymentMethod) return;
|
||||||
|
setDeletingPaymentMethod(paymentMethodId);
|
||||||
|
try {
|
||||||
|
await deletePaymentMethod.mutateAsync(paymentMethodId);
|
||||||
|
} finally {
|
||||||
|
setDeletingPaymentMethod(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefaultPaymentMethod = async (paymentMethodId: string) => {
|
||||||
|
await setDefaultPaymentMethod.mutateAsync(paymentMethodId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = billingLoading;
|
||||||
|
|
||||||
|
// Status badge helper for payments
|
||||||
|
const getPaymentStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
|
||||||
|
succeeded: { bg: 'bg-green-100 dark:bg-green-900/50', text: 'text-green-800 dark:text-green-300', icon: <CheckCircle size={12} /> },
|
||||||
|
refunded: { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-800 dark:text-gray-300', icon: <RefreshCcw size={12} /> },
|
||||||
|
pending: { bg: 'bg-yellow-100 dark:bg-yellow-900/50', text: 'text-yellow-800 dark:text-yellow-300', icon: <Clock size={12} /> },
|
||||||
|
unpaid: { bg: 'bg-red-100 dark:bg-red-900/50', text: 'text-red-800 dark:text-red-300', icon: <AlertCircle size={12} /> },
|
||||||
|
failed: { bg: 'bg-red-100 dark:bg-red-900/50', text: 'text-red-800 dark:text-red-300', icon: <XCircle size={12} /> },
|
||||||
|
};
|
||||||
|
const style = styles[status] || styles.pending;
|
||||||
|
const displayStatus = status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full whitespace-nowrap ${style.bg} ${style.text}`}>
|
||||||
|
{style.icon}
|
||||||
|
{displayStatus}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formatDate = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (dateStr: string | null) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render outstanding payment card
|
||||||
|
const renderOutstandingCard = (item: OutstandingPayment) => {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{item.service_name || item.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{formatDate(item.start_time)} {item.start_time && `at ${formatTime(item.start_time)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{getPaymentStatusBadge(item.payment_status)}
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{item.amount_display}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render payment history card
|
||||||
|
const renderHistoryCard = (item: PaymentHistoryItem) => {
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="p-6 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{item.service_name || item.event_title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{formatDate(item.event_date)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
{t('billing.paidOn', 'Paid on')} {formatDate(item.completed_at || item.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
{getPaymentStatusBadge(item.status)}
|
||||||
|
<span className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{item.amount_display}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render card brand icon/text
|
||||||
|
const getCardBrandDisplay = (brand: string | null) => {
|
||||||
|
const brandMap: Record<string, string> = {
|
||||||
|
visa: 'Visa',
|
||||||
|
mastercard: 'Mastercard',
|
||||||
|
amex: 'American Express',
|
||||||
|
discover: 'Discover',
|
||||||
|
};
|
||||||
|
return brandMap[brand?.toLowerCase() || ''] || brand || 'Card';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('billing.title', 'Billing & Payments')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.description', 'View your payments, outstanding balances, and saved payment methods')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{billingData && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-red-100 dark:bg-red-900/50 rounded-lg">
|
||||||
|
<AlertCircle className="text-red-600 dark:text-red-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.outstanding', 'Outstanding')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{billingData.summary.total_outstanding_display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/50 rounded-lg">
|
||||||
|
<DollarSign className="text-green-600 dark:text-green-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.totalSpent', 'Total Spent')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{billingData.summary.total_spent_display}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/50 rounded-lg">
|
||||||
|
<Receipt className="text-blue-600 dark:text-blue-400" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.payments', 'Payments')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{billingData.summary.payment_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="flex gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{[
|
||||||
|
{ id: 'outstanding' as const, label: t('billing.outstandingTab', 'Outstanding'), icon: AlertCircle, count: billingData?.outstanding.length },
|
||||||
|
{ id: 'history' as const, label: t('billing.historyTab', 'Payment History'), icon: History, count: billingData?.payment_history.length },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={16} />
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && tab.count > 0 && (
|
||||||
|
<span className={`ml-1 px-2 py-0.5 text-xs rounded-full ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300'
|
||||||
|
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
) : billingError ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
|
||||||
|
<AlertCircle className="mx-auto text-red-500 mb-3" size={40} />
|
||||||
|
<p className="text-red-700 dark:text-red-300">
|
||||||
|
{t('billing.errorLoading', 'Unable to load billing information. Please try again later.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : activeTab === 'outstanding' ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Wallet size={20} className="text-brand-500" />
|
||||||
|
{t('billing.outstandingPayments', 'Outstanding Payments')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('billing.outstandingDescription', 'Appointments that require payment')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{billingData && billingData.outstanding.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{billingData.outstanding.map(renderOutstandingCard)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<CheckCircle className="mx-auto text-green-500 mb-3" size={40} />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.noOutstanding', 'No outstanding payments. You\'re all caught up!')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Receipt size={20} className="text-brand-500" />
|
||||||
|
{t('billing.paymentHistory', 'Payment History')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{billingData && billingData.payment_history.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{billingData.payment_history.map(renderHistoryCard)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<History className="mx-auto text-gray-400 mb-3" size={40} />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.noPaymentHistory', 'No payment history yet')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved Payment Methods */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<CreditCard size={20} className="text-brand-500" />
|
||||||
|
{t('billing.savedPaymentMethods', 'Saved Payment Methods')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddPaymentModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('billing.addCard', 'Add Card')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{methodsLoading ? (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Loader2 className="mx-auto animate-spin text-gray-400 mb-3" size={24} />
|
||||||
|
</div>
|
||||||
|
) : paymentMethodsData && paymentMethodsData.payment_methods.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{paymentMethodsData.payment_methods.map((pm) => (
|
||||||
|
<div key={pm.id} className="p-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<CreditCard className="text-gray-400" size={24} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{getCardBrandDisplay(pm.brand)} {t('billing.endingIn', 'ending in')} {pm.last4}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('billing.expires', 'Expires')} {pm.exp_month}/{pm.exp_year}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{pm.is_default ? (
|
||||||
|
<span className="text-xs font-medium text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50 px-2 py-1 rounded">
|
||||||
|
{t('billing.default', 'Default')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefaultPaymentMethod(pm.id)}
|
||||||
|
disabled={setDefaultPaymentMethod.isPending}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 rounded hover:border-brand-300 dark:hover:border-brand-600 transition-colors disabled:opacity-50"
|
||||||
|
title={t('billing.setAsDefault', 'Set as default')}
|
||||||
|
>
|
||||||
|
<Star size={12} />
|
||||||
|
{t('billing.setDefault', 'Set Default')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeletePaymentMethod(pm.id)}
|
||||||
|
disabled={deletingPaymentMethod === pm.id}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded hover:border-red-300 dark:hover:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
|
||||||
|
title={t('billing.removeCard', 'Remove card')}
|
||||||
|
>
|
||||||
|
{deletingPaymentMethod === pm.id ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 size={12} />
|
||||||
|
)}
|
||||||
|
{t('common.remove', 'Remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-100 dark:bg-gray-700 mb-4">
|
||||||
|
<CreditCard className="text-gray-400" size={24} />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
{paymentMethodsData?.message || t('billing.noSavedMethods', 'No saved payment methods')}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddPaymentModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('billing.addPaymentMethod', 'Add Payment Method')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Payment Method Modal */}
|
||||||
|
<AddPaymentMethodModal
|
||||||
|
isOpen={showAddPaymentModal}
|
||||||
|
onClose={() => setShowAddPaymentModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomerBilling;
|
||||||
@@ -7,6 +7,7 @@ URL routes for OAuth email integration endpoints.
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .oauth_views import (
|
from .oauth_views import (
|
||||||
OAuthStatusView,
|
OAuthStatusView,
|
||||||
|
OAuthProvidersView,
|
||||||
GoogleOAuthInitiateView,
|
GoogleOAuthInitiateView,
|
||||||
GoogleOAuthCallbackView,
|
GoogleOAuthCallbackView,
|
||||||
MicrosoftOAuthInitiateView,
|
MicrosoftOAuthInitiateView,
|
||||||
@@ -18,9 +19,10 @@ from .oauth_views import (
|
|||||||
app_name = 'oauth'
|
app_name = 'oauth'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Status
|
# Status (admin only)
|
||||||
path('status/', OAuthStatusView.as_view(), name='status'),
|
path('status/', OAuthStatusView.as_view(), name='status'),
|
||||||
path('providers/', OAuthStatusView.as_view(), name='providers'),
|
# Providers (public - for login page)
|
||||||
|
path('providers/', OAuthProvidersView.as_view(), name='providers'),
|
||||||
|
|
||||||
# Google OAuth
|
# Google OAuth
|
||||||
path('google/initiate/', GoogleOAuthInitiateView.as_view(), name='google-initiate'),
|
path('google/initiate/', GoogleOAuthInitiateView.as_view(), name='google-initiate'),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from rest_framework.views import APIView
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
|
||||||
from platform_admin.permissions import IsPlatformAdmin
|
from platform_admin.permissions import IsPlatformAdmin
|
||||||
from .models import OAuthCredential
|
from .models import OAuthCredential
|
||||||
from .oauth_service import (
|
from .oauth_service import (
|
||||||
@@ -29,6 +31,43 @@ from .permissions import HasFeaturePermission
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvidersView(APIView):
|
||||||
|
"""
|
||||||
|
Public endpoint to get available OAuth login providers.
|
||||||
|
|
||||||
|
GET /api/auth/oauth/providers/
|
||||||
|
|
||||||
|
Returns list of OAuth providers available for login.
|
||||||
|
This endpoint is public (no auth required) for the login page.
|
||||||
|
"""
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# For now, return empty list since OAuth login isn't fully implemented
|
||||||
|
# In the future, this would check tenant settings for enabled providers
|
||||||
|
providers = []
|
||||||
|
|
||||||
|
# Check if Google OAuth is configured at platform level
|
||||||
|
google_service = GoogleOAuthService()
|
||||||
|
if google_service.is_configured():
|
||||||
|
providers.append({
|
||||||
|
'name': 'google',
|
||||||
|
'display_name': 'Google',
|
||||||
|
'icon': 'google',
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check if Microsoft OAuth is configured at platform level
|
||||||
|
microsoft_service = MicrosoftOAuthService()
|
||||||
|
if microsoft_service.is_configured():
|
||||||
|
providers.append({
|
||||||
|
'name': 'microsoft',
|
||||||
|
'display_name': 'Microsoft',
|
||||||
|
'icon': 'microsoft',
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({'providers': providers})
|
||||||
|
|
||||||
|
|
||||||
def get_oauth_redirect_uri(request, provider: str) -> str:
|
def get_oauth_redirect_uri(request, provider: str) -> str:
|
||||||
"""
|
"""
|
||||||
Build the OAuth callback URL.
|
Build the OAuth callback URL.
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class StripeService:
|
|||||||
def list_payment_methods(self, customer_id, type='card'):
|
def list_payment_methods(self, customer_id, type='card'):
|
||||||
"""
|
"""
|
||||||
List payment methods for a customer.
|
List payment methods for a customer.
|
||||||
|
|
||||||
CRITICAL: Uses stripe_account header.
|
CRITICAL: Uses stripe_account header.
|
||||||
"""
|
"""
|
||||||
return stripe.PaymentMethod.list(
|
return stripe.PaymentMethod.list(
|
||||||
@@ -172,6 +172,115 @@ class StripeService:
|
|||||||
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_or_get_customer(self, user):
|
||||||
|
"""
|
||||||
|
Create or retrieve a Stripe Customer for a user.
|
||||||
|
|
||||||
|
CRITICAL: Uses stripe_account header to create customer on connected account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Stripe Customer ID
|
||||||
|
"""
|
||||||
|
# Check if user already has a customer ID for this tenant
|
||||||
|
stripe_customer_id = getattr(user, 'stripe_customer_id', None)
|
||||||
|
|
||||||
|
if stripe_customer_id:
|
||||||
|
# Verify customer exists on connected account
|
||||||
|
try:
|
||||||
|
stripe.Customer.retrieve(
|
||||||
|
stripe_customer_id,
|
||||||
|
stripe_account=self.tenant.stripe_connect_id
|
||||||
|
)
|
||||||
|
return stripe_customer_id
|
||||||
|
except stripe.error.InvalidRequestError:
|
||||||
|
# Customer doesn't exist on this account, create new one
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create new customer on connected account
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=user.email,
|
||||||
|
name=user.full_name or user.username,
|
||||||
|
metadata={
|
||||||
|
'user_id': str(user.id),
|
||||||
|
'tenant_id': str(self.tenant.id),
|
||||||
|
},
|
||||||
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save customer ID to user
|
||||||
|
user.stripe_customer_id = customer.id
|
||||||
|
user.save(update_fields=['stripe_customer_id'])
|
||||||
|
|
||||||
|
return customer.id
|
||||||
|
|
||||||
|
def create_setup_intent(self, customer_id):
|
||||||
|
"""
|
||||||
|
Create a SetupIntent for saving a payment method without charging.
|
||||||
|
|
||||||
|
CRITICAL: Uses stripe_account header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe Customer ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SetupIntent object with client_secret
|
||||||
|
"""
|
||||||
|
return stripe.SetupIntent.create(
|
||||||
|
customer=customer_id,
|
||||||
|
payment_method_types=['card'],
|
||||||
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
def detach_payment_method(self, payment_method_id):
|
||||||
|
"""
|
||||||
|
Detach (remove) a payment method from a customer.
|
||||||
|
|
||||||
|
CRITICAL: Uses stripe_account header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_method_id: Stripe PaymentMethod ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaymentMethod object
|
||||||
|
"""
|
||||||
|
return stripe.PaymentMethod.detach(
|
||||||
|
payment_method_id,
|
||||||
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_default_payment_method(self, customer_id, payment_method_id):
|
||||||
|
"""
|
||||||
|
Set a payment method as the default for a customer.
|
||||||
|
|
||||||
|
CRITICAL: Uses stripe_account header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: Stripe Customer ID
|
||||||
|
payment_method_id: Stripe PaymentMethod ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customer object
|
||||||
|
"""
|
||||||
|
return stripe.Customer.modify(
|
||||||
|
customer_id,
|
||||||
|
invoice_settings={'default_payment_method': payment_method_id},
|
||||||
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_customer(self, customer_id):
|
||||||
|
"""
|
||||||
|
Retrieve a customer to get their default payment method.
|
||||||
|
|
||||||
|
CRITICAL: Uses stripe_account header.
|
||||||
|
"""
|
||||||
|
return stripe.Customer.retrieve(
|
||||||
|
customer_id,
|
||||||
|
stripe_account=self.tenant.stripe_connect_id # CRITICAL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Helper function for easy service instantiation
|
# Helper function for easy service instantiation
|
||||||
def get_stripe_service_for_tenant(tenant):
|
def get_stripe_service_for_tenant(tenant):
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ from .views import (
|
|||||||
CreatePaymentIntentView,
|
CreatePaymentIntentView,
|
||||||
TerminalConnectionTokenView,
|
TerminalConnectionTokenView,
|
||||||
RefundPaymentView,
|
RefundPaymentView,
|
||||||
|
# Customer billing
|
||||||
|
CustomerBillingView,
|
||||||
|
CustomerPaymentMethodsView,
|
||||||
|
CustomerSetupIntentView,
|
||||||
|
CustomerPaymentMethodDeleteView,
|
||||||
|
CustomerPaymentMethodDefaultView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -71,4 +77,11 @@ urlpatterns = [
|
|||||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
||||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
||||||
|
|
||||||
|
# Customer billing endpoints
|
||||||
|
path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'),
|
||||||
|
path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
|
||||||
|
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
|
||||||
|
path('customer/payment-methods/<str:payment_method_id>/', CustomerPaymentMethodDeleteView.as_view(), name='customer-payment-method-delete'),
|
||||||
|
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1539,3 +1539,452 @@ class RefundPaymentView(APIView):
|
|||||||
{'error': f'Refund failed: {str(e)}'},
|
{'error': f'Refund failed: {str(e)}'},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Customer Billing Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CustomerBillingView(APIView):
|
||||||
|
"""
|
||||||
|
Get customer's billing information including payment history and outstanding payments.
|
||||||
|
|
||||||
|
GET /payments/customer/billing/
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- outstanding: Events with pending payments
|
||||||
|
- payment_history: Completed transactions
|
||||||
|
- summary: Total spent, etc.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""Get customer billing data."""
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from schedule.models import Participant
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Verify user is a customer (use uppercase constant from User model)
|
||||||
|
if user.role != User.Role.CUSTOMER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'This endpoint is only for customers'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get ContentType for User model
|
||||||
|
user_content_type = ContentType.objects.get_for_model(User)
|
||||||
|
|
||||||
|
# Find all events where this user is a customer participant
|
||||||
|
customer_participations = Participant.objects.filter(
|
||||||
|
content_type=user_content_type,
|
||||||
|
object_id=user.id,
|
||||||
|
role=Participant.Role.CUSTOMER
|
||||||
|
).select_related('event')
|
||||||
|
|
||||||
|
event_ids = [p.event_id for p in customer_participations]
|
||||||
|
|
||||||
|
# Get all transactions for these events
|
||||||
|
transactions = TransactionLink.objects.filter(
|
||||||
|
event_id__in=event_ids
|
||||||
|
).select_related('event').order_by('-created_at')
|
||||||
|
|
||||||
|
# Split into completed payments and pending
|
||||||
|
payment_history = []
|
||||||
|
for tx in transactions:
|
||||||
|
if tx.status in [TransactionLink.Status.SUCCEEDED, TransactionLink.Status.REFUNDED]:
|
||||||
|
# Get service info from event
|
||||||
|
service_name = ''
|
||||||
|
if tx.event.service:
|
||||||
|
service_name = tx.event.service.name
|
||||||
|
|
||||||
|
payment_history.append({
|
||||||
|
'id': tx.id,
|
||||||
|
'event_id': tx.event_id,
|
||||||
|
'event_title': tx.event.title,
|
||||||
|
'service_name': service_name,
|
||||||
|
'amount': float(tx.amount),
|
||||||
|
'amount_display': f'${tx.amount:.2f}',
|
||||||
|
'currency': tx.currency,
|
||||||
|
'status': tx.status.lower(),
|
||||||
|
'payment_intent_id': tx.payment_intent_id,
|
||||||
|
'created_at': tx.created_at.isoformat(),
|
||||||
|
'completed_at': tx.completed_at.isoformat() if tx.completed_at else None,
|
||||||
|
'event_date': tx.event.start_time.isoformat() if tx.event.start_time else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get events with pending/no payment
|
||||||
|
events_with_payment = set(tx.event_id for tx in transactions if tx.status == TransactionLink.Status.SUCCEEDED)
|
||||||
|
|
||||||
|
outstanding = []
|
||||||
|
for participation in customer_participations:
|
||||||
|
event = participation.event
|
||||||
|
# Skip if already paid or cancelled
|
||||||
|
if event.id in events_with_payment:
|
||||||
|
continue
|
||||||
|
if event.status == 'CANCELLED':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get price from service
|
||||||
|
price = 0
|
||||||
|
service_name = ''
|
||||||
|
if event.service:
|
||||||
|
price = float(event.service.price)
|
||||||
|
service_name = event.service.name
|
||||||
|
|
||||||
|
# Check for pending transaction
|
||||||
|
pending_tx = next(
|
||||||
|
(tx for tx in transactions if tx.event_id == event.id and tx.status == TransactionLink.Status.PENDING),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
outstanding.append({
|
||||||
|
'id': event.id,
|
||||||
|
'title': event.title,
|
||||||
|
'service_name': service_name,
|
||||||
|
'amount': price,
|
||||||
|
'amount_display': f'${price:.2f}',
|
||||||
|
'status': event.status,
|
||||||
|
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||||
|
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||||
|
'payment_status': 'pending' if pending_tx else 'unpaid',
|
||||||
|
'payment_intent_id': pending_tx.payment_intent_id if pending_tx else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate summary
|
||||||
|
total_spent = sum(float(tx.amount) for tx in transactions if tx.status == TransactionLink.Status.SUCCEEDED)
|
||||||
|
total_outstanding = sum(item['amount'] for item in outstanding)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'outstanding': outstanding,
|
||||||
|
'payment_history': payment_history,
|
||||||
|
'summary': {
|
||||||
|
'total_spent': total_spent,
|
||||||
|
'total_spent_display': f'${total_spent:.2f}',
|
||||||
|
'total_outstanding': total_outstanding,
|
||||||
|
'total_outstanding_display': f'${total_outstanding:.2f}',
|
||||||
|
'payment_count': len(payment_history),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPaymentMethodsView(APIView):
|
||||||
|
"""
|
||||||
|
Get/manage customer's saved payment methods.
|
||||||
|
|
||||||
|
GET /payments/customer/payment-methods/
|
||||||
|
|
||||||
|
Note: Payment methods are stored in Stripe, not locally.
|
||||||
|
This requires the customer to have a Stripe Customer ID.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""Get customer's saved payment methods from Stripe."""
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Verify user is a customer (use uppercase constant from User model)
|
||||||
|
if user.role != User.Role.CUSTOMER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'This endpoint is only for customers'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user has a Stripe customer ID
|
||||||
|
stripe_customer_id = getattr(user, 'stripe_customer_id', None)
|
||||||
|
if not stripe_customer_id:
|
||||||
|
# No saved payment methods yet
|
||||||
|
return Response({
|
||||||
|
'payment_methods': [],
|
||||||
|
'has_stripe_customer': False,
|
||||||
|
'message': 'No saved payment methods. Payment methods will be saved when you make your first payment.'
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
stripe_svc = get_stripe_service_for_tenant(request.tenant)
|
||||||
|
payment_methods = stripe_svc.list_payment_methods(stripe_customer_id)
|
||||||
|
|
||||||
|
methods = []
|
||||||
|
for pm in payment_methods.data:
|
||||||
|
methods.append({
|
||||||
|
'id': pm.id,
|
||||||
|
'type': pm.type,
|
||||||
|
'brand': pm.card.brand if pm.card else None,
|
||||||
|
'last4': pm.card.last4 if pm.card else None,
|
||||||
|
'exp_month': pm.card.exp_month if pm.card else None,
|
||||||
|
'exp_year': pm.card.exp_year if pm.card else None,
|
||||||
|
'is_default': pm.id == getattr(user, 'default_payment_method_id', None),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'payment_methods': methods,
|
||||||
|
'has_stripe_customer': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Stripe not configured for tenant - show friendly message
|
||||||
|
return Response({
|
||||||
|
'payment_methods': [],
|
||||||
|
'has_stripe_customer': False,
|
||||||
|
'message': 'Payment methods are not available at this time.'
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Don't expose internal errors to customers
|
||||||
|
return Response({
|
||||||
|
'payment_methods': [],
|
||||||
|
'has_stripe_customer': False,
|
||||||
|
'message': 'Unable to load payment methods. Please try again later.'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerSetupIntentView(APIView):
|
||||||
|
"""
|
||||||
|
Create a SetupIntent for adding a new payment method.
|
||||||
|
|
||||||
|
POST /payments/customer/setup-intent/
|
||||||
|
|
||||||
|
Returns client_secret for Stripe Elements to collect card details.
|
||||||
|
Supports both direct_api and connect payment modes.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
"""Create a SetupIntent for the customer."""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
tenant = request.tenant
|
||||||
|
|
||||||
|
# Verify user is a customer
|
||||||
|
if user.role != User.Role.CUSTOMER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'This endpoint is only for customers'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Debug logging
|
||||||
|
logger.info(f"[CustomerSetupIntentView] Tenant: {tenant.name}")
|
||||||
|
logger.info(f"[CustomerSetupIntentView] payment_mode: {tenant.payment_mode}")
|
||||||
|
logger.info(f"[CustomerSetupIntentView] stripe_connect_id: '{tenant.stripe_connect_id}'")
|
||||||
|
|
||||||
|
stripe_account = None # Will be returned to frontend for Stripe.js initialization
|
||||||
|
|
||||||
|
# Determine which Stripe configuration to use
|
||||||
|
if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
|
||||||
|
# Direct API mode: Use tenant's own API keys
|
||||||
|
stripe.api_key = tenant.stripe_secret_key
|
||||||
|
stripe_account = None # No connected account needed
|
||||||
|
|
||||||
|
# Get or create customer ID
|
||||||
|
customer_id = getattr(user, 'stripe_customer_id', None)
|
||||||
|
if not customer_id:
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=user.email,
|
||||||
|
name=user.get_full_name() or user.username,
|
||||||
|
metadata={
|
||||||
|
'user_id': str(user.id),
|
||||||
|
'tenant': tenant.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
customer_id = customer.id
|
||||||
|
user.stripe_customer_id = customer_id
|
||||||
|
user.save(update_fields=['stripe_customer_id'])
|
||||||
|
|
||||||
|
# Create SetupIntent (no stripe_account needed for direct API)
|
||||||
|
setup_intent = stripe.SetupIntent.create(
|
||||||
|
customer=customer_id,
|
||||||
|
payment_method_types=['card'],
|
||||||
|
)
|
||||||
|
|
||||||
|
elif tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
|
||||||
|
# Connect mode: Use platform's API key with connected account
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
stripe_account = tenant.stripe_connect_id
|
||||||
|
|
||||||
|
stripe_svc = get_stripe_service_for_tenant(tenant)
|
||||||
|
|
||||||
|
# Create or get Stripe customer on connected account
|
||||||
|
customer_id = stripe_svc.create_or_get_customer(user)
|
||||||
|
|
||||||
|
# Create SetupIntent on connected account
|
||||||
|
setup_intent = stripe_svc.create_setup_intent(customer_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Payment not configured
|
||||||
|
logger.warning(f"[CustomerSetupIntentView] Payment not configured for tenant {tenant.name}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Payment methods are not available for this business.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[CustomerSetupIntentView] Created SetupIntent: {setup_intent.id}")
|
||||||
|
logger.info(f"[CustomerSetupIntentView] Returning stripe_account: '{stripe_account}'")
|
||||||
|
|
||||||
|
# For direct_api mode, return the tenant's publishable key
|
||||||
|
# For connect mode, the frontend uses the platform's publishable key
|
||||||
|
publishable_key = None
|
||||||
|
if tenant.payment_mode == 'direct_api' and tenant.stripe_publishable_key:
|
||||||
|
publishable_key = tenant.stripe_publishable_key
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'client_secret': setup_intent.client_secret,
|
||||||
|
'setup_intent_id': setup_intent.id,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'stripe_account': stripe_account or '', # Empty string for direct_api mode
|
||||||
|
'publishable_key': publishable_key, # Only for direct_api mode
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"[CustomerSetupIntentView] ValueError: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Adding payment methods is not available at this time.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"[CustomerSetupIntentView] StripeError: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to add payment method. Please try again later.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[CustomerSetupIntentView] Exception: {e}")
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to add payment method. Please try again later.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPaymentMethodDeleteView(APIView):
|
||||||
|
"""
|
||||||
|
Delete a saved payment method.
|
||||||
|
|
||||||
|
DELETE /payments/customer/payment-methods/<payment_method_id>/
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def delete(self, request, payment_method_id):
|
||||||
|
"""Delete a payment method."""
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Verify user is a customer
|
||||||
|
if user.role != User.Role.CUSTOMER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'This endpoint is only for customers'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe_customer_id = getattr(user, 'stripe_customer_id', None)
|
||||||
|
if not stripe_customer_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No payment methods on file'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stripe_svc = get_stripe_service_for_tenant(request.tenant)
|
||||||
|
|
||||||
|
# Verify the payment method belongs to this customer
|
||||||
|
payment_methods = stripe_svc.list_payment_methods(stripe_customer_id)
|
||||||
|
pm_ids = [pm.id for pm in payment_methods.data]
|
||||||
|
|
||||||
|
if payment_method_id not in pm_ids:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Payment method not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detach the payment method
|
||||||
|
stripe_svc.detach_payment_method(payment_method_id)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Payment method removed successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Stripe not configured - show friendly message
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to remove payment method at this time.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Don't expose internal errors to customers
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to remove payment method. Please try again later.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerPaymentMethodDefaultView(APIView):
|
||||||
|
"""
|
||||||
|
Set a payment method as default.
|
||||||
|
|
||||||
|
POST /payments/customer/payment-methods/<payment_method_id>/default/
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request, payment_method_id):
|
||||||
|
"""Set payment method as default."""
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Verify user is a customer
|
||||||
|
if user.role != User.Role.CUSTOMER:
|
||||||
|
return Response(
|
||||||
|
{'error': 'This endpoint is only for customers'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe_customer_id = getattr(user, 'stripe_customer_id', None)
|
||||||
|
if not stripe_customer_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No payment methods on file'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stripe_svc = get_stripe_service_for_tenant(request.tenant)
|
||||||
|
|
||||||
|
# Verify the payment method belongs to this customer
|
||||||
|
payment_methods = stripe_svc.list_payment_methods(stripe_customer_id)
|
||||||
|
pm_ids = [pm.id for pm in payment_methods.data]
|
||||||
|
|
||||||
|
if payment_method_id not in pm_ids:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Payment method not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set as default
|
||||||
|
stripe_svc.set_default_payment_method(stripe_customer_id, payment_method_id)
|
||||||
|
|
||||||
|
# Update user's default payment method
|
||||||
|
user.default_payment_method_id = payment_method_id
|
||||||
|
user.save(update_fields=['default_payment_method_id'])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Default payment method updated'
|
||||||
|
})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Stripe not configured - show friendly message
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to update payment method at this time.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Don't expose internal errors to customers
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unable to update payment method. Please try again later.'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|||||||
@@ -340,12 +340,12 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
Services are the offerings a business provides (e.g., Haircut, Massage).
|
Services are the offerings a business provides (e.g., Haircut, Massage).
|
||||||
|
|
||||||
Permissions:
|
Permissions:
|
||||||
|
- Must be authenticated
|
||||||
- Subject to MAX_SERVICES quota (hard block on creation)
|
- Subject to MAX_SERVICES quota (hard block on creation)
|
||||||
"""
|
"""
|
||||||
queryset = Service.objects.filter(is_active=True)
|
queryset = Service.objects.filter(is_active=True)
|
||||||
serializer_class = ServiceSerializer
|
serializer_class = ServiceSerializer
|
||||||
# TODO: Re-enable authentication for production
|
permission_classes = [IsAuthenticated, HasQuota('MAX_SERVICES')]
|
||||||
permission_classes = [AllowAny, HasQuota('MAX_SERVICES')] # Temporarily allow unauthenticated access for development
|
|
||||||
|
|
||||||
filterset_fields = ['is_active']
|
filterset_fields = ['is_active']
|
||||||
search_fields = ['name', 'description']
|
search_fields = ['name', 'description']
|
||||||
@@ -353,9 +353,20 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
ordering = ['display_order', 'name']
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return services, optionally including inactive ones."""
|
"""Return services for the current tenant, optionally including inactive ones."""
|
||||||
queryset = Service.objects.all()
|
queryset = Service.objects.all()
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# CRITICAL: Validate user belongs to the current request tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
# User is accessing a tenant they don't belong to - return empty
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# By default only show active services
|
# By default only show active services
|
||||||
show_inactive = self.request.query_params.get('show_inactive', 'false')
|
show_inactive = self.request.query_params.get('show_inactive', 'false')
|
||||||
if show_inactive.lower() != 'true':
|
if show_inactive.lower() != 'true':
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-04 17:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0009_user_archived_by_quota_at_user_is_archived_by_quota'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='default_payment_method_id',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Default Stripe PaymentMethod ID for this customer', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='stripe_customer_id',
|
||||||
|
field=models.CharField(blank=True, default='', help_text="Stripe Customer ID on the tenant's connected account", max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -78,6 +78,20 @@ class User(AbstractUser):
|
|||||||
help_text="When this user was archived due to quota overage"
|
help_text="When this user was archived due to quota overage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Stripe customer fields (for customers to save payment methods)
|
||||||
|
stripe_customer_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Stripe Customer ID on the tenant's connected account"
|
||||||
|
)
|
||||||
|
default_payment_method_id = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Default Stripe PaymentMethod ID for this customer"
|
||||||
|
)
|
||||||
|
|
||||||
# Additional profile fields
|
# Additional profile fields
|
||||||
phone = models.CharField(max_length=20, blank=True)
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
phone_verified = models.BooleanField(
|
phone_verified = models.BooleanField(
|
||||||
|
|||||||
Reference in New Issue
Block a user