+ {/* Header */}
+
+
+ {t('billing.title', 'Billing & Payments')}
+
+
+ {t('billing.description', 'View your payments, outstanding balances, and saved payment methods')}
+
+
+
+ {/* Summary Cards */}
+ {billingData && (
+
+
+
+
+
+
+ {t('billing.outstanding', 'Outstanding')}
+
+
+ {billingData.summary.total_outstanding_display}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('billing.totalSpent', 'Total Spent')}
+
+
+ {billingData.summary.total_spent_display}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('billing.payments', 'Payments')}
+
+
+ {billingData.summary.payment_count}
+
+
+
+
+
+ )}
+
+ {/* Tab Navigation */}
+
+ {[
+ { 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) => (
+
+ ))}
+
+
+ {/* Content */}
+ {isLoading ? (
+
+
+
+ ) : billingError ? (
+
+
+
+ {t('billing.errorLoading', 'Unable to load billing information. Please try again later.')}
+
+
+ ) : activeTab === 'outstanding' ? (
+
+
+
+
+ {t('billing.outstandingPayments', 'Outstanding Payments')}
+
+
+ {t('billing.outstandingDescription', 'Appointments that require payment')}
+
+
+ {billingData && billingData.outstanding.length > 0 ? (
+
+ {billingData.outstanding.map(renderOutstandingCard)}
+
+ ) : (
+
+
+
+ {t('billing.noOutstanding', 'No outstanding payments. You\'re all caught up!')}
+
+
+ )}
+
+ ) : (
+
+
+
+
+ {t('billing.paymentHistory', 'Payment History')}
+
+
+ {billingData && billingData.payment_history.length > 0 ? (
+
+ {billingData.payment_history.map(renderHistoryCard)}
+
+ ) : (
+
+
+
+ {t('billing.noPaymentHistory', 'No payment history yet')}
+
+
+ )}
+
+ )}
+
+ {/* Saved Payment Methods */}
+
+
+
+
+
+ {t('billing.savedPaymentMethods', 'Saved Payment Methods')}
+
+
+
+
+ {methodsLoading ? (
+
+
+
+ ) : paymentMethodsData && paymentMethodsData.payment_methods.length > 0 ? (
+
+ {paymentMethodsData.payment_methods.map((pm) => (
+
+
+
+
+
+ {getCardBrandDisplay(pm.brand)} {t('billing.endingIn', 'ending in')} {pm.last4}
+
+
+ {t('billing.expires', 'Expires')} {pm.exp_month}/{pm.exp_year}
+
+
+
+
+ {pm.is_default ? (
+
+ {t('billing.default', 'Default')}
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ {paymentMethodsData?.message || t('billing.noSavedMethods', 'No saved payment methods')}
+
+
+
+ )}
+
+
+ {/* Add Payment Method Modal */}
+
setShowAddPaymentModal(false)}
+ />
+
+ );
+};
+
+export default CustomerBilling;
diff --git a/smoothschedule/core/oauth_urls.py b/smoothschedule/core/oauth_urls.py
index 52d88c0..9c6902c 100644
--- a/smoothschedule/core/oauth_urls.py
+++ b/smoothschedule/core/oauth_urls.py
@@ -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'),
diff --git a/smoothschedule/core/oauth_views.py b/smoothschedule/core/oauth_views.py
index 68c227e..dfaab11 100644
--- a/smoothschedule/core/oauth_views.py
+++ b/smoothschedule/core/oauth_views.py
@@ -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.
diff --git a/smoothschedule/payments/services.py b/smoothschedule/payments/services.py
index 3fe3efd..5620db6 100644
--- a/smoothschedule/payments/services.py
+++ b/smoothschedule/payments/services.py
@@ -163,7 +163,7 @@ class StripeService:
def list_payment_methods(self, customer_id, type='card'):
"""
List payment methods for a customer.
-
+
CRITICAL: Uses stripe_account header.
"""
return stripe.PaymentMethod.list(
@@ -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):
diff --git a/smoothschedule/payments/urls.py b/smoothschedule/payments/urls.py
index 07832c0..3a44cd8 100644
--- a/smoothschedule/payments/urls.py
+++ b/smoothschedule/payments/urls.py
@@ -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/