diff --git a/frontend/src/api/payments.ts b/frontend/src/api/payments.ts index 562a862..b4f08c2 100644 --- a/frontend/src/api/payments.ts +++ b/frontend/src/api/payments.ts @@ -48,6 +48,8 @@ export interface ConnectAccountInfo { export interface PaymentConfig { payment_mode: PaymentMode; tier: string; + tier_allows_payments: boolean; + stripe_configured: boolean; can_accept_payments: boolean; api_keys: ApiKeysInfo | null; connect_account: ConnectAccountInfo | null; @@ -431,3 +433,114 @@ export const getTransactionDetail = (id: number) => */ export const refundTransaction = (transactionId: number, request?: RefundRequest) => apiClient.post(`/payments/transactions/${transactionId}/refund/`, request || {}); + +// ============================================================================ +// Subscription Plans & Add-ons +// ============================================================================ + +export interface SubscriptionPlan { + id: number; + name: string; + description: string; + plan_type: 'base' | 'addon'; + business_tier: string; + price_monthly: number | null; + price_yearly: number | null; + features: string[]; + permissions: Record; + limits: Record; + transaction_fee_percent: number; + transaction_fee_fixed: number; + is_most_popular: boolean; + show_price: boolean; + stripe_price_id: string; +} + +export interface SubscriptionPlansResponse { + current_tier: string; + current_plan: SubscriptionPlan | null; + plans: SubscriptionPlan[]; + addons: SubscriptionPlan[]; +} + +export interface CheckoutResponse { + checkout_url: string; + session_id: string; +} + +/** + * Get available subscription plans and add-ons. + */ +export const getSubscriptionPlans = () => + apiClient.get('/payments/plans/'); + +/** + * Create a checkout session for upgrading or purchasing add-ons. + */ +export const createCheckoutSession = (planId: number, billingPeriod: 'monthly' | 'yearly' = 'monthly') => + apiClient.post('/payments/checkout/', { + plan_id: planId, + billing_period: billingPeriod, + }); + +// ============================================================================ +// Active Subscriptions +// ============================================================================ + +export interface ActiveSubscription { + id: string; + plan_name: string; + plan_type: 'base' | 'addon'; + status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing'; + current_period_start: string; + current_period_end: string; + cancel_at_period_end: boolean; + cancel_at: string | null; + canceled_at: string | null; + amount: number; + amount_display: string; + interval: 'month' | 'year'; + stripe_subscription_id: string; +} + +export interface SubscriptionsResponse { + subscriptions: ActiveSubscription[]; + has_active_subscription: boolean; +} + +export interface CancelSubscriptionResponse { + success: boolean; + message: string; + cancel_at_period_end: boolean; + current_period_end: string; +} + +export interface ReactivateSubscriptionResponse { + success: boolean; + message: string; +} + +/** + * Get active subscriptions for the current tenant. + */ +export const getSubscriptions = () => + apiClient.get('/payments/subscriptions/'); + +/** + * Cancel a subscription. + * @param subscriptionId - Stripe subscription ID + * @param immediate - If true, cancel immediately. If false, cancel at period end. + */ +export const cancelSubscription = (subscriptionId: string, immediate: boolean = false) => + apiClient.post('/payments/subscriptions/cancel/', { + subscription_id: subscriptionId, + immediate, + }); + +/** + * Reactivate a subscription that was set to cancel at period end. + */ +export const reactivateSubscription = (subscriptionId: string) => + apiClient.post('/payments/subscriptions/reactivate/', { + subscription_id: subscriptionId, + }); diff --git a/frontend/src/components/ConnectOnboardingEmbed.tsx b/frontend/src/components/ConnectOnboardingEmbed.tsx index fc9d468..d2fe302 100644 --- a/frontend/src/components/ConnectOnboardingEmbed.tsx +++ b/frontend/src/components/ConnectOnboardingEmbed.tsx @@ -124,41 +124,41 @@ const ConnectOnboardingEmbed: React.FC = ({ if (isActive) { return (
-
+
- +
-

Stripe Connected

-

+

Stripe Connected

+

Your Stripe account is connected and ready to accept payments.

-
-

Account Details

+
+

Account Details

- Account Type: - {getAccountTypeLabel()} + Account Type: + {getAccountTypeLabel()}
- Status: - + Status: + {connectAccount.status}
- Charges: - + Charges: + Enabled
- Payouts: - + Payouts: + {connectAccount.payouts_enabled ? 'Enabled' : 'Pending'} @@ -172,10 +172,10 @@ const ConnectOnboardingEmbed: React.FC = ({ // Completion state if (loadingState === 'complete') { return ( -
- -

Onboarding Complete!

-

+

+ +

Onboarding Complete!

+

Your Stripe account has been set up. You can now accept payments.

@@ -186,12 +186,12 @@ const ConnectOnboardingEmbed: React.FC = ({ if (loadingState === 'error') { return (
-
+
- +
-

Setup Failed

-

{errorMessage}

+

Setup Failed

+

{errorMessage}

@@ -200,7 +200,7 @@ const ConnectOnboardingEmbed: React.FC = ({ setLoadingState('idle'); setErrorMessage(null); }} - className="w-full px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200" + className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600" > Try Again @@ -212,16 +212,16 @@ const ConnectOnboardingEmbed: React.FC = ({ if (loadingState === 'idle') { return (
-
+
- +
-

Set Up Payments

-

+

Set Up Payments

+

As a {tier} tier business, you'll use Stripe Connect to accept payments. Complete the onboarding process to start accepting payments from your customers.

-
    +
    • Secure payment processing @@ -255,7 +255,7 @@ const ConnectOnboardingEmbed: React.FC = ({ return (
      -

      Initializing payment setup...

      +

      Initializing payment setup...

      ); } @@ -264,15 +264,15 @@ const ConnectOnboardingEmbed: React.FC = ({ if (loadingState === 'ready' && stripeConnectInstance) { return (
      -
      -

      Complete Your Account Setup

      -

      +

      +

      Complete Your Account Setup

      +

      Fill out the information below to finish setting up your payment account. Your information is securely handled by Stripe.

      -
      +
      = ({ business }) => { + const navigate = useNavigate(); const { data: config, isLoading, error, refetch } = usePaymentConfig(); if (isLoading) { @@ -56,6 +60,8 @@ const PaymentSettingsSection: React.FC = ({ busines } const paymentMode = (config?.payment_mode || 'none') as PaymentModeType; + const tierAllowsPayments = config?.tier_allows_payments || false; + const stripeConfigured = config?.stripe_configured || false; const canAcceptPayments = config?.can_accept_payments || false; const tier = config?.tier || business.plan || 'Free'; const isFreeTier = tier === 'Free'; @@ -72,16 +78,24 @@ const PaymentSettingsSection: React.FC = ({ busines // Status badge component const StatusBadge = () => { + if (!tierAllowsPayments) { + return ( + + + Upgrade Required + + ); + } if (canAcceptPayments) { return ( - + Ready ); } return ( - + Setup Required @@ -97,17 +111,17 @@ const PaymentSettingsSection: React.FC = ({ busines }; return ( -
      +
      {/* Header */} -
      +
      -
      - +
      +
      -

      Payment Configuration

      -

      {getModeDescription()}

      +

      Payment Configuration

      +

      {getModeDescription()}

      @@ -162,22 +176,22 @@ const PaymentSettingsSection: React.FC = ({ busines {/* Content */}
      {/* Tier info banner */} -
      +
      - Current Plan: + Current Plan: {tier}
      -
      +
      Payment Mode:{' '} - + {paymentMode === 'direct_api' ? 'Direct API Keys' : paymentMode === 'connect' ? 'Stripe Connect' : 'Not Configured'} @@ -186,31 +200,86 @@ const PaymentSettingsSection: React.FC = ({ busines
      - {/* Tier-specific content */} - {isFreeTier ? ( - refetch()} - /> - ) : ( - refetch()} - /> - )} - - {/* Upgrade notice for free tier with deprecated keys */} - {isFreeTier && config?.api_keys?.status === 'deprecated' && ( -
      -

      - Upgraded to a Paid Plan? -

      -

      - If you've recently upgraded, your API keys have been deprecated. - Please contact support to complete your Stripe Connect setup. -

      + {/* Upgrade prompt when tier doesn't allow payments */} + {!tierAllowsPayments ? ( +
      +
      +
      + +
      +
      +

      + Unlock Online Payments +

      +

      + Your current plan doesn't include online payment processing. Upgrade your subscription + or add the Online Payments add-on to start accepting payments from your customers. +

      +
        +
      • + + Accept credit cards, debit cards, and digital wallets +
      • +
      • + + Automatic invoicing and payment reminders +
      • +
      • + + Secure PCI-compliant payment processing +
      • +
      • + + Detailed transaction history and analytics +
      • +
      +
      + + + Contact Sales + +
      +
      +
      + ) : ( + <> + {/* Tier-specific content */} + {isFreeTier ? ( + refetch()} + /> + ) : ( + refetch()} + /> + )} + + {/* Upgrade notice for free tier with deprecated keys */} + {isFreeTier && config?.api_keys?.status === 'deprecated' && ( +
      +

      + Upgraded to a Paid Plan? +

      +

      + If you've recently upgraded, your API keys have been deprecated. + Please contact support to complete your Stripe Connect setup. +

      +
      + )} + )}
      diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 99863c5..dee8224 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -911,6 +911,7 @@ "back": "Back", "next": "Next", "creating": "Creating account...", + "creatingNote": "We're setting up your database. This may take up to a minute.", "createAccount": "Create Account", "haveAccount": "Already have an account?", "signIn": "Sign in" diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ab66878..a6b4392 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -6,7 +6,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLogin } from '../hooks/useAuth'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import SmoothScheduleLogo from '../components/SmoothScheduleLogo'; import OAuthButtons from '../components/OAuthButtons'; import LanguageSelector from '../components/LanguageSelector'; @@ -141,10 +141,10 @@ const LoginPage: React.FC = () => {
      -
      + Smooth Schedule -
      +
      @@ -171,9 +171,9 @@ const LoginPage: React.FC = () => {
      -
      + -
      +

      {t('auth.welcomeBack')}

      diff --git a/frontend/src/pages/marketing/SignupPage.tsx b/frontend/src/pages/marketing/SignupPage.tsx index e85011c..7352b0b 100644 --- a/frontend/src/pages/marketing/SignupPage.tsx +++ b/frontend/src/pages/marketing/SignupPage.tsx @@ -914,24 +914,31 @@ const SignupPage: React.FC = () => { ) : ( - + {isSubmitting && ( +

      + {t('marketing.signup.creatingNote')} +

      )} - +
      )}
      diff --git a/frontend/src/pages/settings/BillingSettings.tsx b/frontend/src/pages/settings/BillingSettings.tsx index e2a947c..c649cc6 100644 --- a/frontend/src/pages/settings/BillingSettings.tsx +++ b/frontend/src/pages/settings/BillingSettings.tsx @@ -1,41 +1,28 @@ /** * Billing Settings Page * - * Manage subscription plan, payment methods, and view invoices. + * Manage subscription plan, add-ons, payment methods, and view invoices. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { useOutletContext } from 'react-router-dom'; +import { useOutletContext, useSearchParams } from 'react-router-dom'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { CreditCard, Crown, Plus, Trash2, Check, AlertCircle, - FileText, ExternalLink, Wallet, Star + FileText, ExternalLink, Wallet, Star, Loader2, Sparkles, + ArrowRight, Package, Zap, X, RotateCcw, Calendar } from 'lucide-react'; import { Business, User } from '../../types'; - -// Plan details for display -const planDetails: Record = { - Free: { - name: 'Free', - price: '$0/month', - features: ['Up to 10 resources', 'Basic scheduling', 'Email support'], - }, - Starter: { - name: 'Starter', - price: '$29/month', - features: ['Up to 50 resources', 'Custom branding', 'Priority email support', 'API access'], - }, - Professional: { - name: 'Professional', - price: '$79/month', - features: ['Unlimited resources', 'Custom domains', 'Phone support', 'Advanced analytics', 'Team permissions'], - }, - Enterprise: { - name: 'Enterprise', - price: 'Custom', - features: ['All Professional features', 'Dedicated account manager', 'Custom integrations', 'SLA guarantee'], - }, -}; +import { + getSubscriptionPlans, + createCheckoutSession, + getSubscriptions, + cancelSubscription, + reactivateSubscription, + SubscriptionPlan, + ActiveSubscription, +} from '../../api/payments'; const BillingSettings: React.FC = () => { const { t } = useTranslation(); @@ -43,16 +30,111 @@ const BillingSettings: React.FC = () => { business: Business; user: User; }>(); + const [searchParams] = useSearchParams(); const [showAddCard, setShowAddCard] = useState(false); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); + const [selectedPlan, setSelectedPlan] = useState(null); + const [cancellingSubscription, setCancellingSubscription] = useState(null); const isOwner = user.role === 'owner'; + // Check for checkout success/cancel + const checkoutStatus = searchParams.get('checkout'); + const [showCheckoutMessage, setShowCheckoutMessage] = useState(!!checkoutStatus); + + useEffect(() => { + if (checkoutStatus) { + // Clear the URL params after showing message + const timer = setTimeout(() => { + setShowCheckoutMessage(false); + window.history.replaceState({}, '', '/settings/billing'); + }, 5000); + return () => clearTimeout(timer); + } + }, [checkoutStatus]); + + // Fetch subscription plans + const { data: plansData, isLoading: plansLoading } = useQuery({ + queryKey: ['subscriptionPlans'], + queryFn: async () => { + const response = await getSubscriptionPlans(); + return response.data; + }, + }); + + // Checkout mutation + const checkoutMutation = useMutation({ + mutationFn: async (plan: SubscriptionPlan) => { + const response = await createCheckoutSession(plan.id); + return response.data; + }, + onSuccess: (data) => { + // Redirect to Stripe Checkout + window.location.href = data.checkout_url; + }, + }); + + // Fetch active subscriptions + const { data: subscriptionsData, isLoading: subscriptionsLoading, refetch: refetchSubscriptions } = useQuery({ + queryKey: ['subscriptions'], + queryFn: async () => { + const response = await getSubscriptions(); + return response.data; + }, + }); + + // Cancel subscription mutation + const cancelMutation = useMutation({ + mutationFn: async ({ subscriptionId, immediate }: { subscriptionId: string; immediate: boolean }) => { + const response = await cancelSubscription(subscriptionId, immediate); + return response.data; + }, + onSuccess: () => { + setCancellingSubscription(null); + refetchSubscriptions(); + }, + }); + + // Reactivate subscription mutation + const reactivateMutation = useMutation({ + mutationFn: async (subscriptionId: string) => { + const response = await reactivateSubscription(subscriptionId); + return response.data; + }, + onSuccess: () => { + refetchSubscriptions(); + }, + }); + // Mock payment methods - in a real app, these would come from Stripe const [paymentMethods] = useState([ { id: 'pm_1', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2025, isDefault: true }, ]); - const currentPlan = planDetails[business.plan || 'Free'] || planDetails.Free; + const handleUpgrade = (plan: SubscriptionPlan) => { + setSelectedPlan(plan); + if (plan.stripe_price_id) { + checkoutMutation.mutate(plan); + } else { + // Plan not configured for purchase yet + alert('This plan is not available for purchase yet. Please contact support.'); + } + }; + + const formatPrice = (price: number | null) => { + if (price === null) return 'Contact Us'; + return `$${price}/mo`; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const activeSubscriptions = subscriptionsData?.subscriptions || []; if (!isOwner) { return ( @@ -64,8 +146,34 @@ const BillingSettings: React.FC = () => { ); } + const currentTier = plansData?.current_tier || business.plan || 'Free'; + const currentPlan = plansData?.current_plan; + const availablePlans = plansData?.plans || []; + const availableAddons = plansData?.addons || []; + return (
      + {/* Checkout Status Message */} + {showCheckoutMessage && ( +
      + {checkoutStatus === 'success' ? ( +
      + + Your subscription has been updated! Changes may take a few minutes to apply. +
      + ) : ( +
      + + Checkout was cancelled. No changes were made to your subscription. +
      + )} +
      + )} + {/* Header */}

      @@ -73,7 +181,7 @@ const BillingSettings: React.FC = () => { {t('settings.billing.title', 'Plan & Billing')}

      - Manage your subscription, payment methods, and billing history. + Manage your subscription, add-ons, payment methods, and billing history.

      @@ -88,29 +196,177 @@ const BillingSettings: React.FC = () => {
      - {currentPlan.name} - - - {currentPlan.price} + {currentTier} + {currentPlan && ( + + {formatPrice(currentPlan.price_monthly)} + + )}
      -
        - {currentPlan.features.map((feature, idx) => ( -
      • - - {feature} -
      • - ))} -
      + {currentPlan?.features && currentPlan.features.length > 0 && ( +
        + {currentPlan.features.slice(0, 5).map((feature, idx) => ( +
      • + + {feature} +
      • + ))} +
      + )}
      -
      + {/* Active Subscriptions */} +
      +

      + + Active Subscriptions +

      + + {subscriptionsLoading ? ( +
      + +
      + ) : activeSubscriptions.length === 0 ? ( +
      + +

      No active subscriptions.

      +

      Subscribe to a plan to get started.

      +
      + ) : ( +
      + {activeSubscriptions.map((subscription) => ( +
      +
      +
      +
      +

      + {subscription.plan_name} +

      + + {subscription.plan_type === 'addon' ? 'Add-on' : 'Plan'} + + {subscription.cancel_at_period_end && ( + + Cancelling + + )} +
      + +
      +

      + {subscription.amount_display} + /{subscription.interval} +

      +

      + {subscription.cancel_at_period_end ? ( + <>Cancels on {formatDate(subscription.current_period_end)} + ) : ( + <>Next billing: {formatDate(subscription.current_period_end)} + )} +

      +
      +
      + +
      + {subscription.cancel_at_period_end ? ( + + ) : ( + + )} +
      +
      +
      + ))} +
      + )} +
      + + {/* Available Add-ons */} + {availableAddons.length > 0 && ( +
      +

      + + Available Add-ons +

      +

      + Enhance your plan with additional features +

      +
      + {availableAddons.map((addon) => ( +
      +
      +
      +

      {addon.name}

      +

      + {addon.description || 'Enhance your subscription'} +

      +
      + +
      +
      + + {formatPrice(addon.price_monthly)} + + +
      +
      + ))} +
      +
      + )} + {/* Wallet / Credits Summary */}

      @@ -235,24 +491,140 @@ const BillingSettings: React.FC = () => {

      - {/* Notice for Free Plan */} - {business.plan === 'Free' && ( -
      -
      -
      - + {/* Upgrade Modal */} + {showUpgradeModal && ( +
      +
      +
      +
      +

      + + Choose Your Plan +

      + +
      -
      -

      You're on the Free Plan

      -

      - Upgrade to unlock custom domains, advanced features, and priority support. -

      - +
      + ); + })} +
      + )} + + {/* Transaction Fee Notice */} +
      +
      + +
      +

      Transaction Fees

      +

      + Higher tier plans include lower transaction fees on payment processing. + Enterprise plans include custom pricing - contact us for details. +

      +
      +
      +
      +
      + +
      +
      -
      +
      )} {/* Add Card Modal Placeholder */} @@ -281,6 +653,85 @@ const BillingSettings: React.FC = () => {
      )} + + {/* Cancel Subscription Confirmation Modal */} + {cancellingSubscription && ( +
      +
      +
      +
      + +
      +

      + Cancel Subscription +

      +
      + +

      + Are you sure you want to cancel {cancellingSubscription.plan_name}? +

      + +
      +
      +

      + Cancel at period end: Your subscription will remain active until{' '} + {formatDate(cancellingSubscription.current_period_end)}, then it will be cancelled. + You can reactivate at any time before that date. +

      +
      +
      +

      + Cancel immediately: Your subscription will be cancelled right now and you may lose access to features immediately. +

      +
      +
      + + {cancelMutation.isError && ( +
      + Failed to cancel subscription. Please try again. +
      + )} + +
      + + + +
      +
      +
      + )}
      ); }; diff --git a/smoothschedule/core/migrations/0018_add_stripe_customer_id.py b/smoothschedule/core/migrations/0018_add_stripe_customer_id.py new file mode 100644 index 0000000..e5d5257 --- /dev/null +++ b/smoothschedule/core/migrations/0018_add_stripe_customer_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-02 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_alter_tierlimit_feature_code_quotaoverage'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='stripe_customer_id', + field=models.CharField(blank=True, default='', help_text='Stripe Customer ID (cus_xxx) for billing/subscriptions', max_length=50), + ), + ] diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py index 66f2bb1..3c9b34e 100644 --- a/smoothschedule/core/models.py +++ b/smoothschedule/core/models.py @@ -291,6 +291,14 @@ class Tenant(TenantMixin): help_text="Validation error message if keys are invalid" ) + # Stripe Customer (for subscriptions/billing - separate from Connect) + stripe_customer_id = models.CharField( + max_length=50, + blank=True, + default='', + help_text="Stripe Customer ID (cus_xxx) for billing/subscriptions" + ) + # Onboarding tracking initial_setup_complete = models.BooleanField( default=False, diff --git a/smoothschedule/payments/urls.py b/smoothschedule/payments/urls.py index 1844bab..07832c0 100644 --- a/smoothschedule/payments/urls.py +++ b/smoothschedule/payments/urls.py @@ -5,6 +5,12 @@ from django.urls import path from .views import ( # Config status PaymentConfigStatusView, + # Subscription plans & add-ons + SubscriptionPlansView, + CreateCheckoutSessionView, + SubscriptionsView, + CancelSubscriptionView, + ReactivateSubscriptionView, # API Keys (Free Tier) ApiKeysView, ApiKeysValidateView, @@ -33,6 +39,13 @@ urlpatterns = [ # Payment configuration status path('config/status/', PaymentConfigStatusView.as_view(), name='payment-config-status'), + # Subscription plans & add-ons + path('plans/', SubscriptionPlansView.as_view(), name='subscription-plans'), + path('checkout/', CreateCheckoutSessionView.as_view(), name='create-checkout'), + path('subscriptions/', SubscriptionsView.as_view(), name='subscriptions'), + path('subscriptions/cancel/', CancelSubscriptionView.as_view(), name='cancel-subscription'), + path('subscriptions/reactivate/', ReactivateSubscriptionView.as_view(), name='reactivate-subscription'), + # API Keys endpoints (free tier) path('api-keys/', ApiKeysView.as_view(), name='api-keys'), path('api-keys/validate/', ApiKeysValidateView.as_view(), name='api-keys-validate'), diff --git a/smoothschedule/payments/views.py b/smoothschedule/payments/views.py index 4ec9726..b997776 100644 --- a/smoothschedule/payments/views.py +++ b/smoothschedule/payments/views.py @@ -8,12 +8,13 @@ from django.conf import settings from django.utils import timezone from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework import status from decimal import Decimal from .services import get_stripe_service_for_tenant from .models import TransactionLink from schedule.models import Event +from platform_admin.models import SubscriptionPlan # ============================================================================ @@ -76,17 +77,22 @@ class PaymentConfigStatusView(APIView): 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None, } - # Determine if payments can be accepted - can_accept = False + # Determine if Stripe is configured and ready + stripe_configured = False if tenant.payment_mode == 'direct_api' and tenant.stripe_api_key_status == 'active': - can_accept = True + stripe_configured = True elif tenant.payment_mode == 'connect' and tenant.stripe_charges_enabled: - can_accept = True + stripe_configured = True + + # Check if tier/subscription allows payments + tier_allows_payments = tenant.can_accept_payments return Response({ 'payment_mode': tenant.payment_mode, 'tier': tenant.subscription_tier, - 'can_accept_payments': can_accept and tenant.can_accept_payments, + 'tier_allows_payments': tier_allows_payments, + 'stripe_configured': stripe_configured, + 'can_accept_payments': stripe_configured and tier_allows_payments, 'api_keys': api_keys, 'connect_account': connect_account, }) @@ -100,6 +106,406 @@ class PaymentConfigStatusView(APIView): return key[:7] + '*' * (len(key) - 11) + key[-4:] +# ============================================================================ +# Subscription Plans & Add-ons +# ============================================================================ + +class SubscriptionPlansView(APIView): + """ + Get available subscription plans and add-ons for the current business. + + GET /payments/plans/ + Returns plans (base tiers) and available add-ons based on current tier. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + tenant = request.tenant + + # Get all active, public base plans + base_plans = SubscriptionPlan.objects.filter( + is_active=True, + is_public=True, + plan_type='base' + ).order_by('price_monthly') + + # Get all active, public add-ons + all_addons = SubscriptionPlan.objects.filter( + is_active=True, + is_public=True, + plan_type='addon' + ).order_by('price_monthly') + + # Filter add-ons based on what the tenant doesn't already have + # and what's relevant to their tier + available_addons = [] + for addon in all_addons: + # Check if the addon provides something the tenant doesn't have + addon_permissions = addon.permissions or {} + + # Skip if tenant already has this permission from their tier + skip = False + for perm_key, perm_value in addon_permissions.items(): + if perm_value and getattr(tenant, perm_key, False): + skip = True + break + + if not skip: + available_addons.append(addon) + + def serialize_plan(plan): + return { + 'id': plan.id, + 'name': plan.name, + 'description': plan.description, + 'plan_type': plan.plan_type, + 'business_tier': plan.business_tier, + 'price_monthly': float(plan.price_monthly) if plan.price_monthly else None, + 'price_yearly': float(plan.price_yearly) if plan.price_yearly else None, + 'features': plan.features or [], + 'permissions': plan.permissions or {}, + 'limits': plan.limits or {}, + 'transaction_fee_percent': float(plan.transaction_fee_percent), + 'transaction_fee_fixed': float(plan.transaction_fee_fixed), + 'is_most_popular': plan.is_most_popular, + 'show_price': plan.show_price, + 'stripe_price_id': plan.stripe_price_id, + } + + # Determine current tier info + current_tier = tenant.subscription_tier or 'Free' + current_plan = base_plans.filter(business_tier=current_tier).first() + + return Response({ + 'current_tier': current_tier, + 'current_plan': serialize_plan(current_plan) if current_plan else None, + 'plans': [serialize_plan(p) for p in base_plans], + 'addons': [serialize_plan(a) for a in available_addons], + }) + + +class CreateCheckoutSessionView(APIView): + """ + Create a Stripe Checkout session for upgrading or purchasing add-ons. + + POST /payments/checkout/ + Body: { plan_id: number, billing_period: 'monthly' | 'yearly' } + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + plan_id = request.data.get('plan_id') + billing_period = request.data.get('billing_period', 'monthly') + + if not plan_id: + return Response( + {'error': 'plan_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + plan = SubscriptionPlan.objects.get(id=plan_id, is_active=True) + except SubscriptionPlan.DoesNotExist: + return Response( + {'error': 'Plan not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + if not plan.stripe_price_id: + return Response( + {'error': 'This plan is not available for purchase yet'}, + status=status.HTTP_400_BAD_REQUEST + ) + + tenant = request.tenant + stripe.api_key = settings.STRIPE_SECRET_KEY + + try: + # Create or get Stripe customer for the tenant + if not tenant.stripe_customer_id: + customer = stripe.Customer.create( + email=tenant.contact_email or request.user.email, + name=tenant.name, + metadata={ + 'tenant_id': str(tenant.id), + 'tenant_schema': tenant.schema_name, + } + ) + tenant.stripe_customer_id = customer.id + tenant.save() + + # Build success/cancel URLs - point to frontend, not API + # Use tenant subdomain with frontend port + frontend_port = '5173' # Vite dev server + if settings.DEBUG: + base_url = f"http://{tenant.schema_name}.lvh.me:{frontend_port}" + else: + # In production, use the tenant's primary domain + base_url = f"https://{tenant.schema_name}.smoothschedule.com" + success_url = f"{base_url}/settings/billing?checkout=success&session_id={{CHECKOUT_SESSION_ID}}" + cancel_url = f"{base_url}/settings/billing?checkout=cancelled" + + # Create checkout session + session = stripe.checkout.Session.create( + customer=tenant.stripe_customer_id, + mode='subscription' if plan.plan_type == 'base' else 'subscription', + line_items=[{ + 'price': plan.stripe_price_id, + 'quantity': 1, + }], + success_url=success_url, + cancel_url=cancel_url, + metadata={ + 'tenant_id': str(tenant.id), + 'tenant_schema': tenant.schema_name, + 'plan_id': str(plan.id), + 'plan_type': plan.plan_type, + }, + ) + + return Response({ + 'checkout_url': session.url, + 'session_id': session.id, + }) + + except stripe.error.StripeError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class SubscriptionsView(APIView): + """ + Get and manage the tenant's active subscriptions. + + GET /payments/subscriptions/ + Returns list of active subscriptions with their details. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + from datetime import datetime + tenant = request.tenant + stripe.api_key = settings.STRIPE_SECRET_KEY + + if not tenant.stripe_customer_id: + return Response({ + 'subscriptions': [], + 'has_active_subscription': False, + }) + + try: + # Fetch subscriptions from Stripe + # Note: Can't expand data.items.data.price.product (too deep) + # We'll fetch products separately if needed + subscriptions = stripe.Subscription.list( + customer=tenant.stripe_customer_id, + status='all', # Include active, past_due, canceled, etc. + ) + + result = [] + has_active = False + for sub in subscriptions.data: + # Skip fully canceled subscriptions + if sub.status == 'canceled': + continue + + if sub.status in ('active', 'trialing'): + has_active = True + + # Get the first item (we typically have one item per subscription) + # Use bracket notation to avoid conflict with dict.items() method + items_list = sub['items']['data'] if sub.get('items') else [] + first_item = items_list[0] if items_list else None + if not first_item: + continue + + price = first_item.get('price') or first_item.price + product_id = price.get('product') if isinstance(price, dict) else price.product + + # Fetch product details separately + product_name = 'Unknown' + plan_type = 'base' + if product_id: + try: + product = stripe.Product.retrieve(product_id) + product_name = product.name or 'Unknown' + plan_type = (product.metadata or {}).get('plan_type', 'base') + except stripe.error.StripeError: + pass + + # Calculate amount - handle both dict and object access + def get_attr(obj, key, default=None): + """Get attribute from dict or object""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + unit_amount = get_attr(price, 'unit_amount', 0) or 0 + currency = (get_attr(price, 'currency', 'usd') or 'usd').upper() + recurring = get_attr(price, 'recurring') + interval = get_attr(recurring, 'interval', 'month') if recurring else 'month' + + # Format amount for display + if currency == 'USD': + amount_display = f"${unit_amount / 100:.2f}" + else: + amount_display = f"{unit_amount / 100:.2f} {currency}" + + # Get period dates from first_item (new Stripe API) or fall back to sub dates + period_start = first_item.get('current_period_start') or sub.get('start_date') + period_end = first_item.get('current_period_end') or sub.get('billing_cycle_anchor') + + result.append({ + 'id': sub.id, + 'plan_name': product_name, + 'plan_type': plan_type, + 'status': sub.status, + 'current_period_start': datetime.fromtimestamp(period_start).isoformat() if period_start else None, + 'current_period_end': datetime.fromtimestamp(period_end).isoformat() if period_end else None, + 'cancel_at_period_end': sub.cancel_at_period_end, + 'cancel_at': datetime.fromtimestamp(sub.cancel_at).isoformat() if sub.cancel_at else None, + 'canceled_at': datetime.fromtimestamp(sub.canceled_at).isoformat() if sub.canceled_at else None, + 'amount': unit_amount / 100, + 'amount_display': amount_display, + 'interval': interval, + 'stripe_subscription_id': sub.id, + }) + + return Response({ + 'subscriptions': result, + 'has_active_subscription': has_active, + }) + + except stripe.error.StripeError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class CancelSubscriptionView(APIView): + """ + Cancel a subscription. + + POST /payments/subscriptions/cancel/ + Body: { subscription_id: string, immediate: boolean } - If immediate is true, cancel immediately. Otherwise cancel at period end. + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + tenant = request.tenant + subscription_id = request.data.get('subscription_id') + immediate = request.data.get('immediate', False) + stripe.api_key = settings.STRIPE_SECRET_KEY + + if not subscription_id: + return Response( + {'error': 'subscription_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not tenant.stripe_customer_id: + return Response( + {'error': 'No customer account found'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Verify subscription belongs to this customer + subscription = stripe.Subscription.retrieve(subscription_id) + if subscription.customer != tenant.stripe_customer_id: + return Response( + {'error': 'Subscription not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + from datetime import datetime + + if immediate: + # Cancel immediately + canceled = stripe.Subscription.cancel(subscription_id) + message = 'Subscription has been cancelled immediately.' + else: + # Cancel at end of billing period + canceled = stripe.Subscription.modify( + subscription_id, + cancel_at_period_end=True + ) + message = 'Subscription will be cancelled at the end of the billing period.' + + # Get period end from subscription items (new Stripe API) + items_data = canceled.get('items', {}).get('data', []) + period_end = items_data[0].get('current_period_end') if items_data else canceled.get('billing_cycle_anchor') + + return Response({ + 'success': True, + 'message': message, + 'cancel_at_period_end': canceled['cancel_at_period_end'], + 'current_period_end': datetime.fromtimestamp(period_end).isoformat() if period_end else None, + }) + + except stripe.error.StripeError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class ReactivateSubscriptionView(APIView): + """ + Reactivate a subscription that was set to cancel at period end. + + POST /payments/subscriptions/reactivate/ + Body: { subscription_id: string } + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + tenant = request.tenant + subscription_id = request.data.get('subscription_id') + stripe.api_key = settings.STRIPE_SECRET_KEY + + if not subscription_id: + return Response( + {'error': 'subscription_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not tenant.stripe_customer_id: + return Response( + {'error': 'No customer account found'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Verify subscription belongs to this customer + subscription = stripe.Subscription.retrieve(subscription_id) + if subscription.customer != tenant.stripe_customer_id: + return Response( + {'error': 'Subscription not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Reactivate by removing cancel_at_period_end + reactivated = stripe.Subscription.modify( + subscription_id, + cancel_at_period_end=False + ) + + return Response({ + 'success': True, + 'message': 'Subscription has been reactivated.', + }) + + except stripe.error.StripeError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # ============================================================================ # API Keys Endpoints (Free Tier) # ============================================================================ @@ -494,22 +900,45 @@ class ConnectAccountSessionView(APIView): Create an Account Session for embedded Connect. POST /payments/connect/account-session/ + + If no Connect account exists, creates a Custom Connect account first. + Custom accounts are required for embedded onboarding (Standard accounts + require the redirect flow). """ permission_classes = [IsAuthenticated] def post(self, request): """Create account session for embedded components.""" tenant = request.tenant - - if not tenant.stripe_connect_id: - return Response( - {'error': 'No Connect account exists'}, - status=status.HTTP_400_BAD_REQUEST - ) - stripe.api_key = settings.STRIPE_SECRET_KEY try: + # Create Connect account if it doesn't exist + if not tenant.stripe_connect_id: + # Create new Custom Connect account (required for embedded onboarding) + account = stripe.Account.create( + type='custom', + country='US', + email=tenant.contact_email or None, + capabilities={ + 'card_payments': {'requested': True}, + 'transfers': {'requested': True}, + }, + business_type='company', + business_profile={ + 'name': tenant.name, + 'mcc': '7299', # Miscellaneous recreation services + }, + metadata={ + 'tenant_id': str(tenant.id), + 'tenant_schema': tenant.schema_name, + } + ) + tenant.stripe_connect_id = account.id + tenant.stripe_connect_status = 'onboarding' + tenant.payment_mode = 'connect' + tenant.save() + account_session = stripe.AccountSession.create( account=tenant.stripe_connect_id, components={ diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index e6f95ac..e65a66b 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -920,15 +920,76 @@ def signup_view(request): try: with schema_context('public'): # 3. Create Tenant + tier = data.get('tier', 'FREE').upper() + + # Determine permissions based on tier (matches seed_subscription_plans.py) + # Free: minimal permissions + # Starter: payments, sms, email templates + # Professional: + custom domain, api access, masked phone numbers + # Business: + white label, create plugins + # Enterprise: all permissions + + tier_permissions = { + 'FREE': { + 'can_accept_payments': False, + 'can_use_sms_reminders': False, + 'can_use_custom_domain': False, + 'can_api_access': False, + 'can_use_masked_phone_numbers': False, + 'can_white_label': False, + }, + 'STARTER': { + 'can_accept_payments': True, + 'can_use_sms_reminders': True, + 'can_use_custom_domain': False, + 'can_api_access': False, + 'can_use_masked_phone_numbers': False, + 'can_white_label': False, + }, + 'PROFESSIONAL': { + 'can_accept_payments': True, + 'can_use_sms_reminders': True, + 'can_use_custom_domain': True, + 'can_api_access': True, + 'can_use_masked_phone_numbers': True, + 'can_white_label': False, + }, + 'BUSINESS': { + 'can_accept_payments': True, + 'can_use_sms_reminders': True, + 'can_use_custom_domain': True, + 'can_api_access': True, + 'can_use_masked_phone_numbers': True, + 'can_white_label': True, + }, + 'ENTERPRISE': { + 'can_accept_payments': True, + 'can_use_sms_reminders': True, + 'can_use_custom_domain': True, + 'can_api_access': True, + 'can_use_masked_phone_numbers': True, + 'can_white_label': True, + }, + } + + perms = tier_permissions.get(tier, tier_permissions['FREE']) + tenant = Tenant.objects.create( schema_name=subdomain, name=data.get('business_name', subdomain), - subscription_tier=data.get('tier', 'FREE'), + subscription_tier=tier, primary_color='#2563eb', # Default secondary_color='#0ea5e9', # Default # Address info contact_email=email, phone=data.get('phone', ''), + # Tier-based permissions + can_accept_payments=perms['can_accept_payments'], + can_use_sms_reminders=perms['can_use_sms_reminders'], + can_use_custom_domain=perms['can_use_custom_domain'], + can_api_access=perms['can_api_access'], + can_use_masked_phone_numbers=perms['can_use_masked_phone_numbers'], + can_white_label=perms['can_white_label'], ) # 4. Create Domain