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:
poduck
2025-12-04 13:06:30 -05:00
parent 65faaae864
commit b0512a660c
17 changed files with 1725 additions and 54 deletions

View File

@@ -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 />} />

View 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;

View File

@@ -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',
},
},

View File

@@ -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',
},
},

View File

@@ -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');

View 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'] });
},
});
};

View File

@@ -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} />

View File

@@ -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)}

View 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;

View File

@@ -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'),

View File

@@ -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.

View File

@@ -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):

View File

@@ -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'),
]

View File

@@ -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
)

View File

@@ -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':

View File

@@ -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),
),
]

View File

@@ -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(