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 {
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<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) {
return (
<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">
<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">
<h4 className="font-medium text-green-800">Stripe Connected</h4>
<p className="text-sm text-green-700 mt-1">
<h4 className="font-medium text-green-800 dark:text-green-300">Stripe Connected</h4>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
Your Stripe account is connected and ready to accept payments.
</p>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Account Details</h4>
<div className="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Account Details</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Account Type:</span>
<span className="text-gray-900">{getAccountTypeLabel()}</span>
<span className="text-gray-600 dark:text-gray-400">Account Type:</span>
<span className="text-gray-900 dark:text-white">{getAccountTypeLabel()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-800">
<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 dark:bg-green-900/30 text-green-800 dark:text-green-300">
{connectAccount.status}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Charges:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">Charges:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CreditCard size={14} />
Enabled
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Payouts:</span>
<span className="flex items-center gap-1 text-green-600">
<span className="text-gray-600 dark:text-gray-400">Payouts:</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Wallet size={14} />
{connectAccount.payouts_enabled ? 'Enabled' : 'Pending'}
</span>
@@ -172,10 +172,10 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
// Completion state
if (loadingState === 'complete') {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
<CheckCircle className="mx-auto text-green-600 mb-3" size={48} />
<h4 className="font-medium text-green-800 text-lg">Onboarding Complete!</h4>
<p className="text-sm text-green-700 mt-2">
<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 dark:text-green-400 mb-3" size={48} />
<h4 className="font-medium text-green-800 dark:text-green-300 text-lg">Onboarding Complete!</h4>
<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.
</p>
</div>
@@ -186,12 +186,12 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'error') {
return (
<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">
<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">
<h4 className="font-medium text-red-800">Setup Failed</h4>
<p className="text-sm text-red-700 mt-1">{errorMessage}</p>
<h4 className="font-medium text-red-800 dark:text-red-300">Setup Failed</h4>
<p className="text-sm text-red-700 dark:text-red-400 mt-1">{errorMessage}</p>
</div>
</div>
</div>
@@ -200,7 +200,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
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
</button>
@@ -212,16 +212,16 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'idle') {
return (
<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">
<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">
<h4 className="font-medium text-blue-800">Set Up Payments</h4>
<p className="text-sm text-blue-700 mt-1">
<h4 className="font-medium text-blue-800 dark:text-blue-300">Set Up Payments</h4>
<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.
Complete the onboarding process to start accepting payments from your customers.
</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">
<CheckCircle size={14} />
Secure payment processing
@@ -255,7 +255,7 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
return (
<div className="flex flex-col items-center justify-center py-12">
<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>
);
}
@@ -264,15 +264,15 @@ const ConnectOnboardingEmbed: React.FC<ConnectOnboardingEmbedProps> = ({
if (loadingState === 'ready' && stripeConnectInstance) {
return (
<div className="space-y-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600">
<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 dark:text-white mb-2">Complete Your Account Setup</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
Fill out the information below to finish setting up your payment account.
Your information is securely handled by Stripe.
</p>
</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}>
<ConnectAccountOnboarding
onExit={handleOnboardingExit}

View File

@@ -5,6 +5,7 @@
*/
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
CreditCard,
CheckCircle,
@@ -12,6 +13,8 @@ import {
Loader2,
FlaskConical,
Zap,
ArrowUpRight,
Sparkles,
} from 'lucide-react';
import { Business } from '../types';
import { usePaymentConfig } from '../hooks/usePayments';
@@ -25,6 +28,7 @@ interface PaymentSettingsSectionProps {
type PaymentModeType = 'direct_api' | 'connect' | 'none';
const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ business }) => {
const navigate = useNavigate();
const { data: config, isLoading, error, refetch } = usePaymentConfig();
if (isLoading) {
@@ -56,6 +60,8 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ 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<PaymentSettingsSectionProps> = ({ busines
// Status badge component
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) {
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} />
Ready
</span>
);
}
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} />
Setup Required
</span>
@@ -97,17 +111,17 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
};
return (
<div className="bg-white rounded-lg shadow">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
{/* 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 gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<CreditCard className="text-purple-600" size={24} />
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<CreditCard className="text-purple-600 dark:text-purple-400" size={24} />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Payment Configuration</h2>
<p className="text-sm text-gray-500">{getModeDescription()}</p>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Payment Configuration</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{getModeDescription()}</p>
</div>
</div>
<StatusBadge />
@@ -162,22 +176,22 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
{/* Content */}
<div className="p-6">
{/* 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>
<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 ${
tier === 'Enterprise' ? 'bg-purple-100 text-purple-800' :
tier === 'Business' ? 'bg-blue-100 text-blue-800' :
tier === 'Professional' ? 'bg-green-100 text-green-800' :
'bg-gray-100 text-gray-800'
tier === 'Enterprise' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300' :
tier === 'Business' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300' :
tier === 'Professional' ? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300' :
'bg-gray-100 dark:bg-gray-600 text-gray-800 dark:text-gray-200'
}`}>
{tier}
</span>
</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-gray-600 dark:text-gray-400">
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 === 'connect' ? 'Stripe Connect' :
'Not Configured'}
@@ -186,31 +200,86 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
</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>
{/* Upgrade prompt when tier doesn't allow payments */}
{!tierAllowsPayments ? (
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 rounded-xl">
<Sparkles className="text-purple-600" size={28} />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Unlock Online Payments
</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.
</p>
<ul className="space-y-2 mb-6 text-sm text-gray-600">
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Accept credit cards, debit cards, and digital wallets
</li>
<li className="flex items-center gap-2">
<CheckCircle size={16} className="text-green-500" />
Automatic invoicing and payment reminders
</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>
) : (
<>
{/* 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>

View File

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

View File

@@ -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 = () => {
<div className="relative z-10 flex flex-col justify-between w-full p-12">
<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" />
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
</div>
</Link>
</div>
<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="mx-auto w-full max-w-sm lg:max-w-md">
<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" />
</div>
</Link>
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
{t('auth.welcomeBack')}
</h2>

View File

@@ -914,24 +914,31 @@ const SignupPage: React.FC = () => {
<ArrowRight className="w-4 h-4" />
</button>
) : (
<button
type="button"
onClick={handleSubmit}
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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.creating')}
</>
) : (
<>
<CheckCircle className="w-4 h-4" />
{t('marketing.signup.createAccount')}
</>
<div className="flex flex-col items-end gap-2">
<button
type="button"
onClick={handleSubmit}
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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('marketing.signup.creating')}
</>
) : (
<>
<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>

View File

@@ -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<string, { name: string; price: string; features: string[] }> = {
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<SubscriptionPlan | null>(null);
const [cancellingSubscription, setCancellingSubscription] = useState<ActiveSubscription | null>(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 (
<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 */}
<div>
<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')}
</h2>
<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>
</div>
@@ -88,29 +196,177 @@ const BillingSettings: React.FC = () => {
<div className="mt-4">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900 dark:text-white">
{currentPlan.name}
</span>
<span className="text-gray-500 dark:text-gray-400">
{currentPlan.price}
{currentTier}
</span>
{currentPlan && (
<span className="text-gray-500 dark:text-gray-400">
{formatPrice(currentPlan.price_monthly)}
</span>
)}
</div>
<ul className="mt-4 space-y-2">
{currentPlan.features.map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500" />
{feature}
</li>
))}
</ul>
{currentPlan?.features && currentPlan.features.length > 0 && (
<ul className="mt-4 space-y-2">
{currentPlan.features.slice(0, 5).map((feature, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Check size={16} className="text-green-500" />
{feature}
</li>
))}
</ul>
)}
</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} />
Upgrade Plan
{currentTier === 'Enterprise' ? 'Manage Plan' : 'Upgrade Plan'}
</button>
</div>
</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 */}
<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">
@@ -235,24 +491,140 @@ const BillingSettings: React.FC = () => {
</div>
</section>
{/* Notice for Free Plan */}
{business.plan === 'Free' && (
<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="flex items-start gap-4">
<div className="p-3 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<AlertCircle size={24} className="text-amber-600 dark:text-amber-400" />
{/* Upgrade Modal */}
{showUpgradeModal && (
<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-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<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>
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
Upgrade to unlock custom domains, advanced features, and priority support.
</p>
<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">
<Crown size={16} /> View Plans
<div className="p-6">
{plansLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" size={32} />
</div>
) : (
<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>
</div>
</div>
</section>
</div>
)}
{/* Add Card Modal Placeholder */}
@@ -281,6 +653,85 @@ const BillingSettings: React.FC = () => {
</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>
);
};

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"
)
# 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,

View File

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

View File

@@ -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={

View File

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