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:
@@ -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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,6 +200,59 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Tier-specific content */}
|
||||||
{isFreeTier ? (
|
{isFreeTier ? (
|
||||||
<StripeApiKeysForm
|
<StripeApiKeysForm
|
||||||
@@ -212,6 +279,8 @@ const PaymentSettingsSection: React.FC<PaymentSettingsSectionProps> = ({ busines
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -914,6 +914,7 @@ const SignupPage: React.FC = () => {
|
|||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
@@ -932,6 +933,12 @@ const SignupPage: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{isSubmitting && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
{t('marketing.signup.creatingNote')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{currentPlan && (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
{currentPlan.price}
|
{formatPrice(currentPlan.price_monthly)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{currentPlan?.features && currentPlan.features.length > 0 && (
|
||||||
<ul className="mt-4 space-y-2">
|
<ul className="mt-4 space-y-2">
|
||||||
{currentPlan.features.map((feature, idx) => (
|
{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">
|
<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" />
|
<Check size={16} className="text-green-500" />
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</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">
|
||||||
</div>
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<div>
|
<Crown className="text-amber-500" />
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2">You're on the Free Plan</h4>
|
Choose Your Plan
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
</h3>
|
||||||
Upgrade to unlock custom domains, advanced features, and priority support.
|
<button
|
||||||
</p>
|
onClick={() => setShowUpgradeModal(false)}
|
||||||
<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">
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
<Crown size={16} /> View Plans
|
>
|
||||||
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user