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 ResourceDashboard = React.lazy(() => import('./pages/resource/ResourceDashboard'));
|
||||
const BookingPage = React.lazy(() => import('./pages/customer/BookingPage'));
|
||||
const CustomerBilling = React.lazy(() => import('./pages/customer/CustomerBilling'));
|
||||
const TrialExpired = React.lazy(() => import('./pages/TrialExpired'));
|
||||
const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
||||
|
||||
@@ -537,7 +538,7 @@ const AppContent: React.FC = () => {
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/payments" element={<CustomerBilling />} />
|
||||
<Route path="/support" element={<CustomerSupport />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<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',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSizeBase: '14px',
|
||||
spacingUnit: '4px',
|
||||
spacingUnit: '12px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -377,7 +377,7 @@ export const CreditPaymentModal: React.FC<CreditPaymentModalProps> = ({
|
||||
colorText: '#1e293b',
|
||||
colorDanger: '#dc2626',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
spacingUnit: '4px',
|
||||
spacingUnit: '12px',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -88,14 +88,14 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
||||
const handleQuickLogin = async (user: TestUser) => {
|
||||
setLoading(user.email);
|
||||
try {
|
||||
// Call token auth API - username field contains email since we use email as username
|
||||
const response = await apiClient.post('/auth-token/', {
|
||||
username: user.email,
|
||||
// Call custom login API that supports email login
|
||||
const response = await apiClient.post('/auth/login/', {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
});
|
||||
|
||||
// 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
|
||||
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,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { User, Business, PaymentMethod, Customer } from '../types';
|
||||
import { CUSTOMERS } from '../mockData';
|
||||
import { User, Business, PaymentMethod } from '../types';
|
||||
import PaymentSettingsSection from '../components/PaymentSettingsSection';
|
||||
import TransactionDetailModal from '../components/TransactionDetailModal';
|
||||
import Portal from '../components/Portal';
|
||||
@@ -96,43 +95,39 @@ const Payments: React.FC = () => {
|
||||
const exportMutation = useExportTransactions();
|
||||
|
||||
// Customer view state (for customer-facing)
|
||||
const [customerProfile, setCustomerProfile] = useState<Customer | undefined>(
|
||||
CUSTOMERS.find(c => c.userId === effectiveUser.id)
|
||||
);
|
||||
// Initialize with empty payment methods - real data will come from API when implemented
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [isAddCardModalOpen, setIsAddCardModalOpen] = useState(false);
|
||||
|
||||
// Customer handlers
|
||||
const handleSetDefault = (pmId: string) => {
|
||||
if (!customerProfile) return;
|
||||
const updatedMethods = customerProfile.paymentMethods.map(pm => ({
|
||||
const updatedMethods = paymentMethods.map(pm => ({
|
||||
...pm,
|
||||
isDefault: pm.id === pmId
|
||||
}));
|
||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
||||
setPaymentMethods(updatedMethods);
|
||||
};
|
||||
|
||||
const handleDeleteMethod = (pmId: string) => {
|
||||
if (!customerProfile) return;
|
||||
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)) {
|
||||
updatedMethods[0].isDefault = true;
|
||||
}
|
||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
||||
setPaymentMethods(updatedMethods);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCard = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!customerProfile) return;
|
||||
const newCard: PaymentMethod = {
|
||||
id: `pm_${Date.now()}`,
|
||||
brand: 'Visa',
|
||||
last4: String(Math.floor(1000 + Math.random() * 9000)),
|
||||
isDefault: customerProfile.paymentMethods.length === 0
|
||||
isDefault: paymentMethods.length === 0
|
||||
};
|
||||
const updatedMethods = [...customerProfile.paymentMethods, newCard];
|
||||
setCustomerProfile({...customerProfile, paymentMethods: updatedMethods });
|
||||
const updatedMethods = [...paymentMethods, newCard];
|
||||
setPaymentMethods(updatedMethods);
|
||||
setIsAddCardModalOpen(false);
|
||||
};
|
||||
|
||||
@@ -788,7 +783,7 @@ const Payments: React.FC = () => {
|
||||
}
|
||||
|
||||
// Customer View
|
||||
if (isCustomer && customerProfile) {
|
||||
if (isCustomer) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div>
|
||||
@@ -805,7 +800,7 @@ const Payments: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
<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 className="flex items-center gap-4">
|
||||
<CreditCard className="text-gray-400" size={24} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useOutletContext, Link } from 'react-router-dom';
|
||||
import { User, Business, Service, Customer } from '../../types';
|
||||
import { SERVICES, CUSTOMERS } from '../../mockData';
|
||||
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard } from 'lucide-react';
|
||||
import { User, Business, Service } from '../../types';
|
||||
import { useServices } from '../../hooks/useServices';
|
||||
import { Check, ChevronLeft, Calendar, Clock, AlertTriangle, CreditCard, Loader2 } from 'lucide-react';
|
||||
|
||||
const BookingPage: React.FC = () => {
|
||||
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 [selectedService, setSelectedService] = useState<Service | null>(null);
|
||||
@@ -23,10 +24,6 @@ const BookingPage: React.FC = () => {
|
||||
];
|
||||
|
||||
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);
|
||||
setStep(2);
|
||||
};
|
||||
@@ -50,26 +47,25 @@ const BookingPage: React.FC = () => {
|
||||
}
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{SERVICES.map(service => (
|
||||
{services.map(service => (
|
||||
<button
|
||||
key={service.id}
|
||||
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 .oauth_views import (
|
||||
OAuthStatusView,
|
||||
OAuthProvidersView,
|
||||
GoogleOAuthInitiateView,
|
||||
GoogleOAuthCallbackView,
|
||||
MicrosoftOAuthInitiateView,
|
||||
@@ -18,9 +19,10 @@ from .oauth_views import (
|
||||
app_name = 'oauth'
|
||||
|
||||
urlpatterns = [
|
||||
# Status
|
||||
# Status (admin only)
|
||||
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
|
||||
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.permissions import IsAuthenticated
|
||||
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from platform_admin.permissions import IsPlatformAdmin
|
||||
from .models import OAuthCredential
|
||||
from .oauth_service import (
|
||||
@@ -29,6 +31,43 @@ from .permissions import HasFeaturePermission
|
||||
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:
|
||||
"""
|
||||
Build the OAuth callback URL.
|
||||
|
||||
@@ -172,6 +172,115 @@ class StripeService:
|
||||
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
|
||||
def get_stripe_service_for_tenant(tenant):
|
||||
|
||||
@@ -33,6 +33,12 @@ from .views import (
|
||||
CreatePaymentIntentView,
|
||||
TerminalConnectionTokenView,
|
||||
RefundPaymentView,
|
||||
# Customer billing
|
||||
CustomerBillingView,
|
||||
CustomerPaymentMethodsView,
|
||||
CustomerSetupIntentView,
|
||||
CustomerPaymentMethodDeleteView,
|
||||
CustomerPaymentMethodDefaultView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -71,4 +77,11 @@ urlpatterns = [
|
||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
||||
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)}'},
|
||||
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).
|
||||
|
||||
Permissions:
|
||||
- Must be authenticated
|
||||
- Subject to MAX_SERVICES quota (hard block on creation)
|
||||
"""
|
||||
queryset = Service.objects.filter(is_active=True)
|
||||
serializer_class = ServiceSerializer
|
||||
# TODO: Re-enable authentication for production
|
||||
permission_classes = [AllowAny, HasQuota('MAX_SERVICES')] # Temporarily allow unauthenticated access for development
|
||||
permission_classes = [IsAuthenticated, HasQuota('MAX_SERVICES')]
|
||||
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'description']
|
||||
@@ -353,9 +353,20 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
ordering = ['display_order', 'name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return services, optionally including inactive ones."""
|
||||
"""Return services for the current tenant, optionally including inactive ones."""
|
||||
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
|
||||
show_inactive = self.request.query_params.get('show_inactive', 'false')
|
||||
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"
|
||||
)
|
||||
|
||||
# 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
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
phone_verified = models.BooleanField(
|
||||
|
||||
Reference in New Issue
Block a user