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

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