feat: Stripe subscriptions, tier-based permissions, dark mode, and UX improvements

- Fix Stripe SDK v14 compatibility (bracket notation for subscription items)
- Fix subscription period dates from subscription items instead of subscription object
- Add tier-based permissions (can_accept_payments, etc.) on tenant signup
- Add stripe_customer_id field to Tenant model
- Add clickable logo on login page (navigates to /)
- Add database setup message during signup wizard
- Add dark mode support for payment settings and Connect onboarding
- Add subscription management endpoints (cancel, reactivate)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-02 20:50:18 -05:00
parent 08b51d1a5f
commit ef58e9fc94
12 changed files with 1337 additions and 167 deletions

View File

@@ -48,6 +48,8 @@ export interface ConnectAccountInfo {
export interface PaymentConfig { export interface PaymentConfig {
payment_mode: PaymentMode; payment_mode: PaymentMode;
tier: string; tier: string;
tier_allows_payments: boolean;
stripe_configured: boolean;
can_accept_payments: boolean; can_accept_payments: boolean;
api_keys: ApiKeysInfo | null; api_keys: ApiKeysInfo | null;
connect_account: ConnectAccountInfo | null; connect_account: ConnectAccountInfo | null;
@@ -431,3 +433,114 @@ export const getTransactionDetail = (id: number) =>
*/ */
export const refundTransaction = (transactionId: number, request?: RefundRequest) => export const refundTransaction = (transactionId: number, request?: RefundRequest) =>
apiClient.post<RefundResponse>(`/payments/transactions/${transactionId}/refund/`, request || {}); apiClient.post<RefundResponse>(`/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<string, boolean>;
limits: Record<string, number>;
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<SubscriptionPlansResponse>('/payments/plans/');
/**
* Create a checkout session for upgrading or purchasing add-ons.
*/
export const createCheckoutSession = (planId: number, billingPeriod: 'monthly' | 'yearly' = 'monthly') =>
apiClient.post<CheckoutResponse>('/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<SubscriptionsResponse>('/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<CancelSubscriptionResponse>('/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<ReactivateSubscriptionResponse>('/payments/subscriptions/reactivate/', {
subscription_id: subscriptionId,
});

View File

@@ -124,41 +124,41 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (isActive) { if (isActive) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4"> <div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CheckCircle className="text-green-600 shrink-0 mt-0.5" size={20} /> <CheckCircle className="text-green-600 dark:text-green-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1"> <div className="flex-1">
<h4 className="font-medium text-green-800">Stripe Connected</h4> <h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1"> <p className="text-sm text-green-700 dark:text-green-400 mt-1">
Your Stripe account is connected and ready to accept payments. Your Stripe account is connected and ready to accept payments.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4"> <div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4> <h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Account Type:</span> <span className="text-gray-600 dark:text-gray-400">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span> <span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Status:</span> <span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800"> <span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status} {connectAccount.status}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span> <span className="text-gray-600 dark:text-gray-400">Charges:</span>
<span className="flex items-center gap-1 text-green-600"> <span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} /> <CreditCard size={14} />
Enabled Enabled
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span> <span className="text-gray-600 dark:text-gray-400">Payouts:</span>
<span className="flex items-center gap-1 text-green-600"> <span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} /> <Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'} {connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
</span> </span>
@@ -172,10 +172,10 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Completion state // Completion state
if (loadingState === 'complete') { if (loadingState === 'complete') {
return ( return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center"> <div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} /> <CheckCircle className="mx-auto text-green-600 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4> <h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2"> <p className="text-sm text-green-700 dark:text-green-400 mt-2">
Your Stripe account has been set up. You can now accept payments. Your Stripe account has been set up. You can now accept payments.
</p> </p>
</div> </div>
@@ -186,12 +186,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'error') { if (loadingState === 'error') {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-red-50 border border-red-200 rounded-lg p-4"> <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertCircle className="text-red-600 shrink-0 mt-0.5" size={20} /> <AlertCircle className="text-red-600 dark:text-red-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1"> <div className="flex-1">
<h4 className="font-medium text-red-800">Setup Failed</h4> <h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p> <p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -200,7 +200,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
setLoadingState('idle'); setLoadingState('idle');
setErrorMessage(null); 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 Try Again
</button> </button>
@@ -212,16 +212,16 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'idle') { if (loadingState === 'idle') {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Building2 className="text-blue-600 shrink-0 mt-0.5" size={20} /> <Building2 className="text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" size={20} />
<div className="flex-1"> <div className="flex-1">
<h4 className="font-medium text-blue-800">Set Up Payments</h4> <h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1"> <p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
As a {tier} tier business, you'll use Stripe Connect to accept 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. Complete the onboarding process to start accepting payments from your customers.
</p> </p>
<ul className="mt-3 space-y-1 text-sm text-blue-700"> <ul className="mt-3 space-y-1 text-sm text-blue-700 dark:text-blue-400">
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<CheckCircle size={14} /> <CheckCircle size={14} />
Secure payment processing Secure payment processing
@@ -255,7 +255,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return ( return (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} /> <Loader2 className="animate-spin text-[#635BFF] mb-4" size={40} />
<p className="text-gray-600">Initializing payment setup...</p> <p className="text-gray-600 dark:text-gray-400">Initializing payment setup...</p>
</div> </div>
); );
} }
@@ -264,15 +264,15 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'ready' && stripeConnectInstance) { if (loadingState === 'ready' && stripeConnectInstance) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4> <h4 className="font-medium text-gray-900 dark:text-white mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-400">
Fill out the information below to finish setting up your payment account. Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe. Your information is securely handled by Stripe.
</p> </p>
</div> </div>
<div className="border border-gray-200 rounded-lg overflow-hidden bg-white p-4"> <div className="border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 p-4">
<ConnectComponentsProvider connectInstance={stripeConnectInstance}> <ConnectComponentsProvider connectInstance={stripeConnectInstance}>
<ConnectAccountOnboarding <ConnectAccountOnboarding
onExit={handleOnboardingExit} onExit={handleOnboardingExit}

View File

@@ -5,6 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
CreditCard, CreditCard,
CheckCircle, CheckCircle,
@@ -12,6 +13,8 @@ import {
Loader2, Loader2,
FlaskConical, FlaskConical,
Zap, Zap,
ArrowUpRight,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { Business } from '../types'; import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments'; import { usePaymentConfig } from '../hooks/usePayments';
@@ -25,6 +28,7 @@ interface PaymentSettingsSectionProps {
type PaymentModeType = 'direct_api' | 'connect' | 'none'; type PaymentModeType = 'direct_api' | 'connect' | 'none';
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => { const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
const navigate = useNavigate();
const { data: config, isLoading, error, refetch } = usePaymentConfig(); const { data: config, isLoading, error, refetch } = usePaymentConfig();
if (isLoading) { if (isLoading) {
@@ -56,6 +60,8 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
} }
const paymentMode = (config?.payment_mode || 'none') as PaymentModeType; 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 canAcceptPayments = config?.can_accept_payments || false;
const tier = config?.tier || business.plan || 'Free'; const tier = config?.tier || business.plan || 'Free';
const isFreeTier = tier === 'Free'; const isFreeTier = tier === 'Free';
@@ -72,16 +78,24 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
// Status badge component // Status badge component
const StatusBadge = () => { const StatusBadge = () => {
if (!tierAllowsPayments) {
return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full">
<AlertCircle size={12} />
Upgrade Required
</span>
);
}
if (canAcceptPayments) { if (canAcceptPayments) {
return ( return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full"> <span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 rounded-full">
<CheckCircle size={12} /> <CheckCircle size={12} />
Ready Ready
</span> </span>
); );
} }
return ( return (
<span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full"> <span className="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded-full">
<AlertCircle size={12} /> <AlertCircle size={12} />
Setup Required Setup Required
</span> </span>
@@ -97,17 +111,17 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
}; };
return ( return (
<div className="bg-white rounded-lg shadow"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow">
{/* Header */} {/* Header */}
<div className="p-6 border-b border-gray-200"> <div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<CreditCard className="text-purple-600" size={24} /> <CreditCard className="text-purple-600 dark:text-purple-400" size={24} />
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-white">Payment Configuration</h2>
<p className="text-sm text-gray-500">{getModeDescription()}</p> <p className="text-sm text-gray-500 dark:text-gray-400">{getModeDescription()}</p>
</div> </div>
</div> </div>
<StatusBadge /> <StatusBadge />
@@ -162,22 +176,22 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
{/* Tier info banner */} {/* Tier info banner */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg"> <div className="mb-6 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="text-sm text-gray-600">Current Plan:</span> <span className="text-sm text-gray-600 dark:text-gray-400">Current Plan:</span>
<span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${ <span className={`ml-2 px-2 py-0.5 text-xs font-semibold rounded-full ${
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' : tier === 'Enterprise' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' :
tier === 'Business' ? 'bg-blue-100 text-blue-800' : tier === 'Business' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' :
tier === 'Professional' ? 'bg-green-100 text-green-800' : tier === 'Professional' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' :
'bg-gray-100 text-gray-800' 'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200'
}`}> }`}>
{tier} {tier}
</span> </span>
</div> </div>
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600 dark:text-gray-400">
Payment Mode:{' '} Payment Mode:{' '}
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900 dark:text-white">
{paymentMode === 'direct_api' ? 'Direct API Keys' : {paymentMode === 'direct_api' ? 'Direct API Keys' :
paymentMode === 'connect' ? 'Stripe Connect' : paymentMode === 'connect' ? 'Stripe Connect' :
'Not Configured'} 'Not Configured'}
@@ -186,31 +200,86 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
</div> </div>
</div> </div>
{/* Tier-specific content */} {/* Upgrade prompt when tier doesn't allow payments */}
{isFreeTier ? ( {!tierAllowsPayments ? (
<StripeApiKeysForm <div className="bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6">
apiKeys={config?.api_keys || null} <div className="flex items-start gap-4">
onSuccess={() => refetch()} <div className="p-3 bg-purple-100 rounded-xl">
/> <Sparkles className="text-purple-600" size={28} />
) : ( </div>
<ConnectOnboardingEmbed <div className="flex-1">
connectAccount={config?.connect_account || null} <h3 className="text-lg font-semibold text-gray-900 mb-2">
tier={tier} Unlock Online Payments
onComplete={() => refetch()} </h3>
/> <p className="text-gray-600 mb-4">
)} 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.
{/* Upgrade notice for free tier with deprecated keys */} </p>
{isFreeTier && config?.api_keys?.status === 'deprecated' && ( <ul className="space-y-2 mb-6 text-sm text-gray-600">
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <li className="flex items-center gap-2">
<h4 className="font-medium text-blue-800 mb-1"> <CheckCircle size={16} className="text-green-500" />
Upgraded to a Paid Plan? Accept credit cards, debit cards, and digital wallets
</h4> </li>
<p className="text-sm text-blue-700"> <li className="flex items-center gap-2">
If you've recently upgraded, your API keys have been deprecated. <CheckCircle size={16} className="text-green-500" />
Please contact support to complete your Stripe Connect setup. Automatic invoicing and payment reminders
</p> </li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Secure PCI-compliant payment processing
</li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Detailed transaction history and analytics
</li>
</ul>
<div className="flex flex-wrap gap-3">
<button
onClick={() => navigate('/settings/billing')}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors"
>
<ArrowUpRight size={16} />
View Upgrade Options
</button>
<a
href="mailto:support@smoothschedule.com?subject=Online Payments Add-on"
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
>
Contact Sales
</a>
</div>
</div>
</div>
</div> </div>
) : (
<>
{/* Tier-specific content */}
{isFreeTier ? (
<StripeApiKeysForm
apiKeys={config?.api_keys || null}
onSuccess={() => refetch()}
/>
) : (
<ConnectOnboardingEmbed
connectAccount={config?.connect_account || null}
tier={tier}
onComplete={() => refetch()}
/>
)}
{/* Upgrade notice for free tier with deprecated keys */}
{isFreeTier && config?.api_keys?.status === 'deprecated' && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-medium text-blue-800 mb-1">
Upgraded to a Paid Plan?
</h4>
<p className="text-sm text-blue-700">
If you've recently upgraded, your API keys have been deprecated.
Please contact support to complete your Stripe Connect setup.
</p>
</div>
)}
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -911,6 +911,7 @@
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"creating": "Creating account...", "creating": "Creating account...",
"creatingNote": "We're setting up your database. This may take up to a minute.",
"createAccount": "Create Account", "createAccount": "Create Account",
"haveAccount": "Already have an account?", "haveAccount": "Already have an account?",
"signIn": "Sign in" "signIn": "Sign in"

View File

@@ -6,7 +6,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLogin } from '../hooks/useAuth'; import { useLogin } from '../hooks/useAuth';
import { useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import SmoothScheduleLogo from '../components/SmoothScheduleLogo'; import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
import OAuthButtons from '../components/OAuthButtons'; import OAuthButtons from '../components/OAuthButtons';
import LanguageSelector from '../components/LanguageSelector'; import LanguageSelector from '../components/LanguageSelector';
@@ -141,10 +141,10 @@ const LoginPage: React.FC = () => {
<div className="relative z-10 flex flex-col justify-between w-full p-12"> <div className="relative z-10 flex flex-col justify-between w-full p-12">
<div> <div>
<div className="flex items-center gap-3 text-white/90"> <Link to="/" className="flex items-center gap-3 text-white/90 hover:text-white transition-colors">
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" /> <SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span> <span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
</div> </Link>
</div> </div>
<div className="space-y-6 max-w-md"> <div className="space-y-6 max-w-md">
@@ -171,9 +171,9 @@ const LoginPage: React.FC = () => {
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900"> <div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
<div className="mx-auto w-full max-w-sm lg:max-w-md"> <div className="mx-auto w-full max-w-sm lg:max-w-md">
<div className="text-center lg:text-left mb-10"> <div className="text-center lg:text-left mb-10">
<div className="lg:hidden flex justify-center mb-6"> <Link to="/" className="lg:hidden flex justify-center mb-6">
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" /> <SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
</div> </Link>
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight"> <h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
{t('auth.welcomeBack')} {t('auth.welcomeBack')}
</h2> </h2>

View File

@@ -914,24 +914,31 @@ const SignupPage: React.FC = () => {
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</button> </button>
) : ( ) : (
<button <div className="flex flex-col items-end gap-2">
type="button" <button
onClick={handleSubmit} type="button"
disabled={isSubmitting} onClick={handleSubmit}
className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium" disabled={isSubmitting}
> className="flex items-center gap-2 px-6 py-3 bg-brand-600 text-white rounded-xl hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
{isSubmitting ? ( >
<> {isSubmitting ? (
<Loader2 className="w-4 h-4 animate-spin" /> <>
{t('marketing.signup.creating')} <Loader2 className="w-4 h-4 animate-spin" />
</> {t('marketing.signup.creating')}
) : ( </>
<> ) : (
<CheckCircle className="w-4 h-4" /> <>
{t('marketing.signup.createAccount')} <CheckCircle className="w-4 h-4" />
</> {t('marketing.signup.createAccount')}
</>
)}
</button>
{isSubmitting && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-right">
{t('marketing.signup.creatingNote')}
</p>
)} )}
</button> </div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,41 +1,28 @@
/** /**
* Billing Settings Page * 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 { 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 { import {
CreditCard, Crown, Plus, Trash2, Check, AlertCircle, 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'; } from 'lucide-react';
import { Business, User } from '../../types'; import { Business, User } from '../../types';
import {
// Plan details for display getSubscriptionPlans,
const planDetails: Record<string, { name: string; price: string; features: string[] }> = { createCheckoutSession,
Free: { getSubscriptions,
name: 'Free', cancelSubscription,
price: '$0/month', reactivateSubscription,
features: ['Up to 10 resources', 'Basic scheduling', 'Email support'], SubscriptionPlan,
}, ActiveSubscription,
Starter: { } from '../../api/payments';
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'],
},
};
const BillingSettings: React.FC = () => { const BillingSettings: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,16 +30,111 @@ const BillingSettings: React.FC = () => {
business: Business; business: Business;
user: User; user: User;
}>(); }>();
const [searchParams] = useSearchParams();
const [showAddCard, setShowAddCard] = useState(false); const [showAddCard, setShowAddCard] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
const [cancellingSubscription, setCancellingSubscription] = useState<ActiveSubscription | null>(null);
const isOwner = user.role === 'owner'; 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 // Mock payment methods - in a real app, these would come from Stripe
const [paymentMethods] = useState([ const [paymentMethods] = useState([
{ id: 'pm_1', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2025, isDefault: true }, { 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) { if (!isOwner) {
return ( 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Checkout Status Message */}
{showCheckoutMessage && (
<div className={`p-4 rounded-lg ${
checkoutStatus === 'success'
? 'bg-green-50 border border-green-200 text-green-800'
: 'bg-yellow-50 border border-yellow-200 text-yellow-800'
}`}>
{checkoutStatus === 'success' ? (
<div className="flex items-center gap-2">
<Check size={20} />
<span>Your subscription has been updated! Changes may take a few minutes to apply.</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle size={20} />
<span>Checkout was cancelled. No changes were made to your subscription.</span>
</div>
)}
</div>
)}
{/* Header */} {/* Header */}
<div> <div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
@@ -73,7 +181,7 @@ const BillingSettings: React.FC = () => {
{t('settings.billing.title', 'Plan & Billing')} {t('settings.billing.title', 'Plan & Billing')}
</h2> </h2>
<p className="text-gray-500 dark:text-gray-400 mt-1"> <p className="text-gray-500 dark:text-gray-400 mt-1">
Manage your subscription, payment methods, and billing history. Manage your subscription, add-ons, payment methods, and billing history.
</p> </p>
</div> </div>
@@ -88,29 +196,177 @@ const BillingSettings: React.FC = () => {
<div className="mt-4"> <div className="mt-4">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-white"> <span className="text-3xl font-bold text-gray-900 dark:text-white">
{currentPlan.name} {currentTier}
</span>
<span className="text-gray-500 dark:text-gray-400">
{currentPlan.price}
</span> </span>
{currentPlan && (
<span className="text-gray-500 dark:text-gray-400">
{formatPrice(currentPlan.price_monthly)}
</span>
)}
</div> </div>
<ul className="mt-4 space-y-2"> {currentPlan?.features && currentPlan.features.length > 0 && (
{currentPlan.features.map((feature, idx) => ( <ul className="mt-4 space-y-2">
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> {currentPlan.features.slice(0, 5).map((feature, idx) => (
<Check size={16} className="text-green-500" /> <li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
{feature} <Check size={16} className="text-green-500" />
</li> {feature}
))} </li>
</ul> ))}
</ul>
)}
</div> </div>
</div> </div>
<button className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2"> <button
onClick={() => setShowUpgradeModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all flex items-center gap-2"
>
<Crown size={16} /> <Crown size={16} />
Upgrade Plan {currentTier === 'Enterprise' ? 'Manage Plan' : 'Upgrade Plan'}
</button> </button>
</div> </div>
</section> </section>
{/* Active Subscriptions */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Calendar size={20} className="text-blue-500" />
Active Subscriptions
</h3>
{subscriptionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="animate-spin text-gray-400" size={24} />
</div>
) : activeSubscriptions.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<Calendar size={40} className="mx-auto mb-2 opacity-30" />
<p>No active subscriptions.</p>
<p className="text-sm mt-1">Subscribe to a plan to get started.</p>
</div>
) : (
<div className="space-y-4">
{activeSubscriptions.map((subscription) => (
<div
key={subscription.id}
className={`border rounded-lg p-4 ${
subscription.cancel_at_period_end
? 'border-yellow-300 bg-yellow-50 dark:border-yellow-700 dark:bg-yellow-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">
{subscription.plan_name}
</h4>
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${
subscription.plan_type === 'addon'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
}`}>
{subscription.plan_type === 'addon' ? 'Add-on' : 'Plan'}
</span>
{subscription.cancel_at_period_end && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
Cancelling
</span>
)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">
<p>
<span className="font-medium">{subscription.amount_display}</span>
<span className="text-gray-500">/{subscription.interval}</span>
</p>
<p>
{subscription.cancel_at_period_end ? (
<>Cancels on {formatDate(subscription.current_period_end)}</>
) : (
<>Next billing: {formatDate(subscription.current_period_end)}</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{subscription.cancel_at_period_end ? (
<button
onClick={() => reactivateMutation.mutate(subscription.stripe_subscription_id)}
disabled={reactivateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-green-600 border border-green-600 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors disabled:opacity-50"
>
{reactivateMutation.isPending ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
Reactivate
</button>
) : (
<button
onClick={() => setCancellingSubscription(subscription)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-red-600 border border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<X size={14} />
Cancel
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</section>
{/* Available Add-ons */}
{availableAddons.length > 0 && (
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
<Package size={20} className="text-purple-500" />
Available Add-ons
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Enhance your plan with additional features
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableAddons.map((addon) => (
<div
key={addon.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 dark:hover:border-purple-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-medium text-gray-900 dark:text-white">{addon.name}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{addon.description || 'Enhance your subscription'}
</p>
</div>
<Sparkles size={20} className="text-purple-500" />
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-lg font-semibold text-gray-900 dark:text-white">
{formatPrice(addon.price_monthly)}
</span>
<button
onClick={() => handleUpgrade(addon)}
disabled={checkoutMutation.isPending}
className="px-3 py-1.5 text-sm font-medium text-purple-600 border border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50"
>
{checkoutMutation.isPending && selectedPlan?.id === addon.id ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Add'
)}
</button>
</div>
</div>
))}
</div>
</section>
)}
{/* Wallet / Credits Summary */} {/* Wallet / Credits Summary */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm"> <section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2 mb-4">
@@ -235,24 +491,140 @@ const BillingSettings: React.FC = () => {
</div> </div>
</section> </section>
{/* Notice for Free Plan */} {/* Upgrade Modal */}
{business.plan === 'Free' && ( {showUpgradeModal && (
<section className="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-xl border border-amber-200 dark:border-amber-800"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="flex items-start gap-4"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg"> <div className="p-6 border-b border-gray-200 dark:border-gray-700">
<AlertCircle size={24} className="text-amber-600 dark:text-amber-400" /> <div className="flex items-center justify-between">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Crown className="text-amber-500" />
Choose Your Plan
</h3>
<button
onClick={() => setShowUpgradeModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
×
</button>
</div>
</div> </div>
<div>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4> <div className="p-6">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3"> {plansLoading ? (
Upgrade to unlock custom domains, advanced features, and priority support. <div className="flex items-center justify-center py-12">
</p> <Loader2 className="animate-spin text-gray-400" size={32} />
<button className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-lg hover:from-indigo-600 hover:to-purple-600 transition-all"> </div>
<Crown size={16} /> View Plans ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availablePlans.map((plan) => {
const isCurrentPlan = plan.business_tier === currentTier;
const isUpgrade = (plan.price_monthly || 0) > (currentPlan?.price_monthly || 0);
return (
<div
key={plan.id}
className={`relative border rounded-xl p-5 ${
plan.is_most_popular
? 'border-purple-500 ring-2 ring-purple-500/20'
: 'border-gray-200 dark:border-gray-700'
} ${isCurrentPlan ? 'bg-gray-50 dark:bg-gray-900/50' : ''}`}
>
{plan.is_most_popular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 text-xs font-semibold text-white bg-purple-500 rounded-full">
Most Popular
</span>
</div>
)}
<div className="text-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 dark:text-white">
{plan.name}
</h4>
<div className="mt-2">
{plan.show_price ? (
<>
<span className="text-3xl font-bold text-gray-900 dark:text-white">
${plan.price_monthly || 0}
</span>
<span className="text-gray-500 dark:text-gray-400">/mo</span>
</>
) : (
<span className="text-xl font-semibold text-gray-900 dark:text-white">
Contact Us
</span>
)}
</div>
</div>
<ul className="space-y-2 mb-6">
{(plan.features || []).slice(0, 6).map((feature, idx) => (
<li key={idx} className="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500 shrink-0 mt-0.5" />
{feature}
</li>
))}
</ul>
<button
onClick={() => {
if (!isCurrentPlan) {
handleUpgrade(plan);
}
}}
disabled={isCurrentPlan || checkoutMutation.isPending}
className={`w-full py-2.5 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 ${
isCurrentPlan
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
: plan.is_most_popular
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-100'
}`}
>
{checkoutMutation.isPending && selectedPlan?.id === plan.id ? (
<Loader2 size={18} className="animate-spin" />
) : isCurrentPlan ? (
'Current Plan'
) : isUpgrade ? (
<>
Upgrade <ArrowRight size={16} />
</>
) : (
'Select'
)}
</button>
</div>
);
})}
</div>
)}
{/* Transaction Fee Notice */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-start gap-3">
<Zap className="text-blue-600 shrink-0 mt-0.5" size={20} />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-300">Transaction Fees</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
Higher tier plans include lower transaction fees on payment processing.
Enterprise plans include custom pricing - contact us for details.
</p>
</div>
</div>
</div>
</div>
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => setShowUpgradeModal(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Close
</button> </button>
</div> </div>
</div> </div>
</section> </div>
)} )}
{/* Add Card Modal Placeholder */} {/* Add Card Modal Placeholder */}
@@ -281,6 +653,85 @@ const BillingSettings: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Cancel Subscription Confirmation Modal */}
{cancellingSubscription && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full">
<AlertCircle className="text-red-600 dark:text-red-400" size={24} />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Cancel Subscription
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Are you sure you want to cancel <strong>{cancellingSubscription.plan_name}</strong>?
</p>
<div className="space-y-3 mb-6">
<div className="p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
<p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Cancel at period end:</strong> Your subscription will remain active until{' '}
<strong>{formatDate(cancellingSubscription.current_period_end)}</strong>, then it will be cancelled.
You can reactivate at any time before that date.
</p>
</div>
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-700 dark:text-red-300">
<strong>Cancel immediately:</strong> Your subscription will be cancelled right now and you may lose access to features immediately.
</p>
</div>
</div>
{cancelMutation.isError && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-300">
Failed to cancel subscription. Please try again.
</div>
)}
<div className="flex flex-col gap-2">
<button
onClick={() => cancelMutation.mutate({
subscriptionId: cancellingSubscription.stripe_subscription_id,
immediate: false,
})}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2.5 text-sm font-medium text-yellow-700 bg-yellow-100 rounded-lg hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-300 dark:hover:bg-yellow-900/50 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{cancelMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Cancel at Period End'
)}
</button>
<button
onClick={() => cancelMutation.mutate({
subscriptionId: cancellingSubscription.stripe_subscription_id,
immediate: true,
})}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{cancelMutation.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Cancel Immediately'
)}
</button>
<button
onClick={() => setCancellingSubscription(null)}
disabled={cancelMutation.isPending}
className="w-full px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Keep Subscription
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

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

View File

@@ -291,6 +291,14 @@ class Tenant(TenantMixin):
help_text="Validation error message if keys are invalid" 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 # Onboarding tracking
initial_setup_complete = models.BooleanField( initial_setup_complete = models.BooleanField(
default=False, default=False,

View File

@@ -5,6 +5,12 @@ from django.urls import path
from .views import ( from .views import (
# Config status # Config status
PaymentConfigStatusView, PaymentConfigStatusView,
# Subscription plans & add-ons
SubscriptionPlansView,
CreateCheckoutSessionView,
SubscriptionsView,
CancelSubscriptionView,
ReactivateSubscriptionView,
# API Keys (Free Tier) # API Keys (Free Tier)
ApiKeysView, ApiKeysView,
ApiKeysValidateView, ApiKeysValidateView,
@@ -33,6 +39,13 @@ urlpatterns = [
# Payment configuration status # Payment configuration status
path('config/status/', PaymentConfigStatusView.as_view(), name='payment-config-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) # API Keys endpoints (free tier)
path('api-keys/', ApiKeysView.as_view(), name='api-keys'), path('api-keys/', ApiKeysView.as_view(), name='api-keys'),
path('api-keys/validate/', ApiKeysValidateView.as_view(), name='api-keys-validate'), path('api-keys/validate/', ApiKeysValidateView.as_view(), name='api-keys-validate'),

View File

@@ -8,12 +8,13 @@ from django.conf import settings
from django.utils import timezone from django.utils import timezone
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response 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 rest_framework import status
from decimal import Decimal from decimal import Decimal
from .services import get_stripe_service_for_tenant from .services import get_stripe_service_for_tenant
from .models import TransactionLink from .models import TransactionLink
from schedule.models import Event 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, 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None,
} }
# Determine if payments can be accepted # Determine if Stripe is configured and ready
can_accept = False stripe_configured = False
if tenant.payment_mode == 'direct_api' and tenant.stripe_api_key_status == 'active': 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: 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({ return Response({
'payment_mode': tenant.payment_mode, 'payment_mode': tenant.payment_mode,
'tier': tenant.subscription_tier, '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, 'api_keys': api_keys,
'connect_account': connect_account, 'connect_account': connect_account,
}) })
@@ -100,6 +106,406 @@ class PaymentConfigStatusView(APIView):
return key[:7] + '*' * (len(key) - 11) + key[-4:] 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) # API Keys Endpoints (Free Tier)
# ============================================================================ # ============================================================================
@@ -494,22 +900,45 @@ class ConnectAccountSessionView(APIView):
Create an Account Session for embedded Connect. Create an Account Session for embedded Connect.
POST /payments/connect/account-session/ 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] permission_classes = [IsAuthenticated]
def post(self, request): def post(self, request):
"""Create account session for embedded components.""" """Create account session for embedded components."""
tenant = request.tenant 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 stripe.api_key = settings.STRIPE_SECRET_KEY
try: 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_session = stripe.AccountSession.create(
account=tenant.stripe_connect_id, account=tenant.stripe_connect_id,
components={ components={

View File

@@ -920,15 +920,76 @@ def signup_view(request):
try: try:
with schema_context('public'): with schema_context('public'):
# 3. Create Tenant # 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( tenant = Tenant.objects.create(
schema_name=subdomain, schema_name=subdomain,
name=data.get('business_name', subdomain), name=data.get('business_name', subdomain),
subscription_tier=data.get('tier', 'FREE'), subscription_tier=tier,
primary_color='#2563eb', # Default primary_color='#2563eb', # Default
secondary_color='#0ea5e9', # Default secondary_color='#0ea5e9', # Default
# Address info # Address info
contact_email=email, contact_email=email,
phone=data.get('phone', ''), 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 # 4. Create Domain