feat: Quota overage system, updated tier pricing, and communication addons

Quota Overage System:
- Add QuotaOverage model for tracking resource/user quota overages
- Implement 30-day grace period with email notifications (immediate, 7-day, 1-day)
- Add QuotaWarningBanner component in BusinessLayout
- Add QuotaSettings page for managing overages and archiving resources
- Add Celery tasks for automated quota checks and expiration handling
- Add quota management API endpoints

Updated Tier Pricing (Stripe: 2.9% + $0.30):
- Free: No payments (requires addon)
- Starter: 4% + $0.40
- Professional: 3.5% + $0.35
- Business: 3.25% + $0.32
- Enterprise: 3% + $0.30

New Subscription Addons:
- Online Payments ($5/mo + 5% + $0.50) - for Free tier
- SMS Notifications ($10/mo) - enables SMS reminders
- Masked Calling ($15/mo) - enables anonymous calling

BusinessEditModal Improvements:
- Increased width to match PlanModal (max-w-3xl)
- Added all tier options with auto-update on tier change
- Added limits configuration and permissions sections

Backend Fixes:
- Fixed SubscriptionPlan serializer to include all communication fields
- Allow blank business_tier for addon plans
- Added migration for business_tier field changes

🤖 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 13:05:02 -05:00
parent dc3210927a
commit 08b51d1a5f
36 changed files with 3469 additions and 350 deletions

View File

@@ -84,6 +84,7 @@ const AuthenticationSettings = React.lazy(() => import('./pages/settings/Authent
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings')); const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings')); const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings')); const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
@@ -705,6 +706,7 @@ const AppContent: React.FC = () => {
<Route path="email" element={<EmailSettings />} /> <Route path="email" element={<EmailSettings />} />
<Route path="sms-calling" element={<CommunicationSettings />} /> <Route path="sms-calling" element={<CommunicationSettings />} />
<Route path="billing" element={<BillingSettings />} /> <Route path="billing" element={<BillingSettings />} />
<Route path="quota" element={<QuotaSettings />} />
</Route> </Route>
) : ( ) : (
<Route path="/settings/*" element={<Navigate to="/" />} /> <Route path="/settings/*" element={<Navigate to="/" />} />

View File

@@ -11,6 +11,17 @@ export interface LoginCredentials {
import { UserRole } from '../types'; import { UserRole } from '../types';
export interface QuotaOverage {
id: number;
quota_type: string;
display_name: string;
current_usage: number;
allowed_limit: number;
overage_amount: number;
days_remaining: number;
grace_period_ends_at: string;
}
export interface MasqueradeStackEntry { export interface MasqueradeStackEntry {
user_id: number; user_id: number;
username: string; username: string;
@@ -58,6 +69,10 @@ export interface User {
business?: number; business?: number;
business_name?: string; business_name?: string;
business_subdomain?: string; business_subdomain?: string;
permissions?: Record<string, boolean>;
can_invite_staff?: boolean;
can_access_tickets?: boolean;
quota_overages?: QuotaOverage[];
} }
/** /**

103
frontend/src/api/quota.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Quota Management API
*/
import apiClient from './client';
import { QuotaOverage } from './auth';
export interface QuotaUsage {
current: number;
limit: number;
display_name: string;
}
export interface QuotaStatus {
active_overages: QuotaOverage[];
usage: Record<string, QuotaUsage>;
}
export interface QuotaResource {
id: number;
name: string;
email?: string;
role?: string;
type?: string;
duration?: number;
price?: string;
created_at: string | null;
is_archived: boolean;
archived_at: string | null;
}
export interface QuotaResourcesResponse {
quota_type: string;
resources: QuotaResource[];
}
export interface ArchiveResponse {
archived_count: number;
current_usage: number;
limit: number;
is_resolved: boolean;
}
export interface QuotaOverageDetail extends QuotaOverage {
status: string;
created_at: string;
initial_email_sent_at: string | null;
week_reminder_sent_at: string | null;
day_reminder_sent_at: string | null;
archived_resource_ids: number[];
}
/**
* Get current quota status
*/
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get<QuotaStatus>('/quota/status/');
return response.data;
};
/**
* Get resources for a specific quota type
*/
export const getQuotaResources = async (quotaType: string): Promise<QuotaResourcesResponse> => {
const response = await apiClient.get<QuotaResourcesResponse>(`/quota/resources/${quotaType}/`);
return response.data;
};
/**
* Archive resources to resolve quota overage
*/
export const archiveResources = async (
quotaType: string,
resourceIds: number[]
): Promise<ArchiveResponse> => {
const response = await apiClient.post<ArchiveResponse>('/quota/archive/', {
quota_type: quotaType,
resource_ids: resourceIds,
});
return response.data;
};
/**
* Unarchive a resource
*/
export const unarchiveResource = async (
quotaType: string,
resourceId: number
): Promise<{ success: boolean; resource_id: number }> => {
const response = await apiClient.post('/quota/unarchive/', {
quota_type: quotaType,
resource_id: resourceId,
});
return response.data;
};
/**
* Get details for a specific overage
*/
export const getOverageDetail = async (overageId: number): Promise<QuotaOverageDetail> => {
const response = await apiClient.get<QuotaOverageDetail>(`/quota/overages/${overageId}/`);
return response.data;
};

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { AlertTriangle, X, ExternalLink } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { QuotaOverage } from '../api/auth';
interface QuotaWarningBannerProps {
overages: QuotaOverage[];
onDismiss?: () => void;
}
const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDismiss }) => {
const { t } = useTranslation();
if (!overages || overages.length === 0) {
return null;
}
// Find the most urgent overage (least days remaining)
const mostUrgent = overages.reduce((prev, curr) =>
curr.days_remaining < prev.days_remaining ? curr : prev
);
const isUrgent = mostUrgent.days_remaining <= 7;
const isCritical = mostUrgent.days_remaining <= 1;
const getBannerStyles = () => {
if (isCritical) {
return 'bg-red-600 text-white border-red-700';
}
if (isUrgent) {
return 'bg-amber-500 text-white border-amber-600';
}
return 'bg-amber-100 text-amber-900 border-amber-300';
};
const getIconColor = () => {
if (isCritical || isUrgent) {
return 'text-white';
}
return 'text-amber-600';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className={`border-b ${getBannerStyles()}`}>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<AlertTriangle className={`h-5 w-5 flex-shrink-0 ${getIconColor()}`} />
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<span className="font-medium">
{isCritical
? t('quota.banner.critical', 'URGENT: Automatic archiving tomorrow!')
: isUrgent
? t('quota.banner.urgent', 'Action Required: {{days}} days left', { days: mostUrgent.days_remaining })
: t('quota.banner.warning', 'Quota exceeded for {{count}} item(s)', { count: overages.length })
}
</span>
<span className="text-sm opacity-90">
{t('quota.banner.details',
'You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.',
{
overage: mostUrgent.overage_amount,
type: mostUrgent.display_name,
date: formatDate(mostUrgent.grace_period_ends_at)
}
)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
to="/settings/quota"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isCritical || isUrgent
? 'bg-white/20 hover:bg-white/30 text-white'
: 'bg-amber-600 hover:bg-amber-700 text-white'
}`}
>
{t('quota.banner.manage', 'Manage Quota')}
<ExternalLink className="h-4 w-4" />
</Link>
{onDismiss && (
<button
onClick={onDismiss}
className={`p-1 rounded-md transition-colors ${
isCritical || isUrgent
? 'hover:bg-white/20'
: 'hover:bg-amber-200'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Show additional overages if there are more than one */}
{overages.length > 1 && (
<div className="mt-2 text-sm opacity-90">
<span className="font-medium">{t('quota.banner.allOverages', 'All overages:')}</span>
<ul className="ml-4 mt-1 space-y-0.5">
{overages.map((overage) => (
<li key={overage.id}>
{overage.display_name}: {overage.current_usage}/{overage.allowed_limit}
({t('quota.banner.overBy', 'over by {{amount}}', { amount: overage.overage_amount })})
{' - '}
{overage.days_remaining <= 0
? t('quota.banner.expiredToday', 'expires today!')
: t('quota.banner.daysLeft', '{{days}} days left', { days: overage.days_remaining })
}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
export default QuotaWarningBanner;

View File

@@ -543,7 +543,11 @@
"acceptPayments": "Accept Payments", "acceptPayments": "Accept Payments",
"acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.", "acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.",
"stripeSetupRequired": "Stripe Connect Setup Required", "stripeSetupRequired": "Stripe Connect Setup Required",
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started." "stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started.",
"quota": {
"title": "Quota Management",
"description": "Usage limits, archiving"
}
}, },
"profile": { "profile": {
"title": "Profile Settings", "title": "Profile Settings",
@@ -1051,6 +1055,36 @@
"dataRetention": "Your data is safe and will be retained for 30 days." "dataRetention": "Your data is safe and will be retained for 30 days."
} }
}, },
"quota": {
"banner": {
"critical": "URGENT: Automatic archiving tomorrow!",
"urgent": "Action Required: {{days}} days left",
"warning": "Quota exceeded for {{count}} item(s)",
"details": "You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.",
"manage": "Manage Quota",
"allOverages": "All overages:",
"overBy": "over by {{amount}}",
"expiredToday": "expires today!",
"daysLeft": "{{days}} days left"
},
"page": {
"title": "Quota Management",
"subtitle": "Manage your account limits and usage",
"currentUsage": "Current Usage",
"planLimit": "Plan Limit",
"overBy": "Over Limit By",
"gracePeriodEnds": "Grace Period Ends",
"daysRemaining": "{{days}} days remaining",
"selectToArchive": "Select items to archive",
"archiveSelected": "Archive Selected",
"upgradeInstead": "Upgrade Plan Instead",
"exportData": "Export Data",
"archiveWarning": "Archived items will become read-only and cannot be used for new bookings.",
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
"noOverages": "You are within your plan limits.",
"resolved": "Resolved! Your usage is now within limits."
}
},
"upgrade": { "upgrade": {
"title": "Upgrade Your Plan", "title": "Upgrade Your Plan",
"subtitle": "Choose the perfect plan for {{businessName}}", "subtitle": "Choose the perfect plan for {{businessName}}",

View File

@@ -4,6 +4,7 @@ import Sidebar from '../components/Sidebar';
import TopBar from '../components/TopBar'; import TopBar from '../components/TopBar';
import TrialBanner from '../components/TrialBanner'; import TrialBanner from '../components/TrialBanner';
import SandboxBanner from '../components/SandboxBanner'; import SandboxBanner from '../components/SandboxBanner';
import QuotaWarningBanner from '../components/QuotaWarningBanner';
import { Business, User } from '../types'; import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner'; import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard'; import OnboardingWizard from '../components/OnboardingWizard';
@@ -283,6 +284,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
onStop={handleStopMasquerade} onStop={handleStopMasquerade}
/> />
)} )}
{/* Quota overage warning banner - show for owners and managers */}
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaWarningBanner overages={user.quota_overages} />
)}
{/* Sandbox mode banner */} {/* Sandbox mode banner */}
<SandboxBannerWrapper /> <SandboxBannerWrapper />
{/* Show trial banner if trial is active and payments not yet enabled */} {/* Show trial banner if trial is active and payments not yet enabled */}

View File

@@ -20,6 +20,7 @@ import {
Phone, Phone,
CreditCard, CreditCard,
Webhook, Webhook,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { import {
SettingsSidebarSection, SettingsSidebarSection,
@@ -137,6 +138,12 @@ const SettingsLayout: React.FC = () => {
label={t('settings.billing.title', 'Plan & Billing')} label={t('settings.billing.title', 'Plan & Billing')}
description={t('settings.billing.description', 'Subscription, invoices')} description={t('settings.billing.description', 'Subscription, invoices')}
/> />
<SettingsSidebarItem
to="/settings/quota"
icon={AlertTriangle}
label={t('settings.quota.title', 'Quota Management')}
description={t('settings.quota.description', 'Usage limits, archiving')}
/>
</SettingsSidebarSection> </SettingsSidebarSection>
</nav> </nav>
</aside> </aside>

View File

@@ -785,12 +785,14 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
can_api_access: false, can_api_access: false,
can_use_masked_phone_numbers: false, can_use_masked_phone_numbers: false,
}, },
// Default transaction fees: Stripe charges 2.9% + $0.30, so we need to charge more
// Recommended: FREE 5%+50¢, STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢
transaction_fee_percent: plan?.transaction_fee_percent transaction_fee_percent: plan?.transaction_fee_percent
? parseFloat(plan.transaction_fee_percent) ? parseFloat(plan.transaction_fee_percent)
: 0, : 4.0, // Default 4% for new plans
transaction_fee_fixed: plan?.transaction_fee_fixed transaction_fee_fixed: plan?.transaction_fee_fixed
? parseFloat(plan.transaction_fee_fixed) ? parseFloat(plan.transaction_fee_fixed)
: 0, : 40, // Default 40 cents for new plans
// Communication pricing // Communication pricing
sms_enabled: plan?.sms_enabled ?? false, sms_enabled: plan?.sms_enabled ?? false,
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3, sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
@@ -1048,6 +1050,9 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
/> />
</div> </div>
</div> </div>
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
Stripe charges 2.9% + $0.30 per transaction + $2/mo per connected account. Recommended: STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢. Consider a payments add-on ($5/mo) for FREE tier users.
</p>
</div> </div>
{/* Communication Pricing */} {/* Communication Pricing */}
@@ -1258,7 +1263,7 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users Max Additional Users
</label> </label>
<input <input
type="number" type="number"

View File

@@ -1,8 +1,57 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, Save, Key } from 'lucide-react'; import { X, Save, Key, RefreshCw } from 'lucide-react';
import { useUpdateBusiness } from '../../../hooks/usePlatform'; import { useUpdateBusiness } from '../../../hooks/usePlatform';
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
import { PlatformBusiness } from '../../../api/platform'; import { PlatformBusiness } from '../../../api/platform';
// Default tier settings - used when no subscription plans are loaded
const TIER_DEFAULTS: Record<string, {
max_users: number;
max_resources: number;
can_manage_oauth_credentials: boolean;
can_accept_payments: boolean;
can_use_custom_domain: boolean;
can_white_label: boolean;
can_api_access: boolean;
}> = {
FREE: {
max_users: 2,
max_resources: 5,
can_manage_oauth_credentials: false,
can_accept_payments: false,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
STARTER: {
max_users: 5,
max_resources: 15,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: false,
can_white_label: false,
can_api_access: false,
},
PROFESSIONAL: {
max_users: 15,
max_resources: 50,
can_manage_oauth_credentials: false,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: false,
can_api_access: true,
},
ENTERPRISE: {
max_users: -1, // unlimited
max_resources: -1, // unlimited
can_manage_oauth_credentials: true,
can_accept_payments: true,
can_use_custom_domain: true,
can_white_label: true,
can_api_access: true,
},
};
interface BusinessEditModalProps { interface BusinessEditModalProps {
business: PlatformBusiness | null; business: PlatformBusiness | null;
isOpen: boolean; isOpen: boolean;
@@ -11,34 +60,144 @@ interface BusinessEditModalProps {
const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => { const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => {
const updateBusinessMutation = useUpdateBusiness(); const updateBusinessMutation = useUpdateBusiness();
const { data: subscriptionPlans } = useSubscriptionPlans();
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
name: '', name: '',
is_active: true, is_active: true,
subscription_tier: 'FREE', subscription_tier: 'FREE',
// Limits
max_users: 5, max_users: 5,
max_resources: 10, max_resources: 10,
max_services: 0,
max_appointments: 0,
max_email_templates: 0,
max_automated_tasks: 0,
// Platform Permissions
can_manage_oauth_credentials: false, can_manage_oauth_credentials: false,
can_accept_payments: false, can_accept_payments: false,
can_use_custom_domain: false, can_use_custom_domain: false,
can_white_label: false, can_white_label: false,
can_api_access: false, can_api_access: false,
// New feature limits (not yet implemented) // Extended Permissions
limits: { permissions: {
can_add_video_conferencing: false, // Payments & Revenue
max_event_types: null as number | null, can_process_refunds: false,
max_calendars_connected: null as number | null, can_create_packages: false,
can_connect_to_api: false, // Communication
can_book_repeated_events: false, sms_reminders: false,
can_require_2fa: false,
can_download_logs: false,
can_delete_data: false,
can_use_masked_phone_numbers: false, can_use_masked_phone_numbers: false,
can_use_pos: false, can_use_email_templates: false,
can_use_mobile_app: false, // Customization
can_customize_booking_page: false,
// Advanced Features
advanced_reporting: false,
can_create_plugins: false,
can_export_data: false,
can_use_webhooks: false,
calendar_sync: false,
// Support & Enterprise
priority_support: false,
dedicated_support: false,
sso_enabled: false,
}, },
}); });
// Get tier defaults from subscription plans or fallback to static defaults
const getTierDefaults = (tier: string) => {
// Try to find matching subscription plan
if (subscriptionPlans) {
const tierNameMap: Record<string, string> = {
'FREE': 'Free',
'STARTER': 'Starter',
'PROFESSIONAL': 'Professional',
'ENTERPRISE': 'Enterprise',
};
const plan = subscriptionPlans.find(p =>
p.business_tier === tierNameMap[tier] || p.business_tier === tier
);
if (plan) {
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
return {
// Limits
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
max_services: plan.limits?.max_services ?? 0,
max_appointments: plan.limits?.max_appointments ?? 0,
max_email_templates: plan.limits?.max_email_templates ?? 0,
max_automated_tasks: plan.limits?.max_automated_tasks ?? 0,
// Platform Permissions
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
can_use_custom_domain: plan.permissions?.can_use_custom_domain ?? staticDefaults.can_use_custom_domain,
can_white_label: plan.permissions?.can_white_label ?? staticDefaults.can_white_label,
can_api_access: plan.permissions?.can_api_access ?? staticDefaults.can_api_access,
// Extended Permissions
permissions: {
can_process_refunds: plan.permissions?.can_process_refunds ?? false,
can_create_packages: plan.permissions?.can_create_packages ?? false,
sms_reminders: plan.permissions?.sms_reminders ?? false,
can_use_masked_phone_numbers: plan.permissions?.can_use_masked_phone_numbers ?? false,
can_use_email_templates: plan.permissions?.can_use_email_templates ?? false,
can_customize_booking_page: plan.permissions?.can_customize_booking_page ?? false,
advanced_reporting: plan.permissions?.advanced_reporting ?? false,
can_create_plugins: plan.permissions?.can_create_plugins ?? false,
can_export_data: plan.permissions?.can_export_data ?? false,
can_use_webhooks: plan.permissions?.can_use_webhooks ?? false,
calendar_sync: plan.permissions?.calendar_sync ?? false,
priority_support: plan.permissions?.priority_support ?? false,
dedicated_support: plan.permissions?.dedicated_support ?? false,
sso_enabled: plan.permissions?.sso_enabled ?? false,
},
};
}
}
// Fallback to static defaults
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
return {
...staticDefaults,
max_services: 0,
max_appointments: 0,
max_email_templates: 0,
max_automated_tasks: 0,
permissions: {
can_process_refunds: false,
can_create_packages: false,
sms_reminders: false,
can_use_masked_phone_numbers: false,
can_use_email_templates: false,
can_customize_booking_page: false,
advanced_reporting: false,
can_create_plugins: false,
can_export_data: false,
can_use_webhooks: false,
calendar_sync: false,
priority_support: false,
dedicated_support: false,
sso_enabled: false,
},
};
};
// Handle subscription tier change - auto-update limits and permissions
const handleTierChange = (newTier: string) => {
const defaults = getTierDefaults(newTier);
setEditForm(prev => ({
...prev,
subscription_tier: newTier,
...defaults,
}));
};
// Reset to tier defaults button handler
const handleResetToTierDefaults = () => {
const defaults = getTierDefaults(editForm.subscription_tier);
setEditForm(prev => ({
...prev,
...defaults,
}));
};
// Update form when business changes // Update form when business changes
useEffect(() => { useEffect(() => {
if (business) { if (business) {
@@ -46,30 +205,51 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
name: business.name, name: business.name,
is_active: business.is_active, is_active: business.is_active,
subscription_tier: business.tier, subscription_tier: business.tier,
// Limits
max_users: business.max_users || 5, max_users: business.max_users || 5,
max_resources: business.max_resources || 10, max_resources: business.max_resources || 10,
max_services: (business as any).max_services || 0,
max_appointments: (business as any).max_appointments || 0,
max_email_templates: (business as any).max_email_templates || 0,
max_automated_tasks: (business as any).max_automated_tasks || 0,
// Platform Permissions
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false, can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
can_accept_payments: business.can_accept_payments || false, can_accept_payments: business.can_accept_payments || false,
can_use_custom_domain: business.can_use_custom_domain || false, can_use_custom_domain: business.can_use_custom_domain || false,
can_white_label: business.can_white_label || false, can_white_label: business.can_white_label || false,
can_api_access: business.can_api_access || false, can_api_access: business.can_api_access || false,
limits: { // Extended Permissions
can_add_video_conferencing: false, permissions: {
max_event_types: null, can_process_refunds: (business as any).permissions?.can_process_refunds || false,
max_calendars_connected: null, can_create_packages: (business as any).permissions?.can_create_packages || false,
can_connect_to_api: false, sms_reminders: (business as any).permissions?.sms_reminders || false,
can_book_repeated_events: false, can_use_masked_phone_numbers: (business as any).permissions?.can_use_masked_phone_numbers || false,
can_require_2fa: false, can_use_email_templates: (business as any).permissions?.can_use_email_templates || false,
can_download_logs: false, can_customize_booking_page: (business as any).permissions?.can_customize_booking_page || false,
can_delete_data: false, advanced_reporting: (business as any).permissions?.advanced_reporting || false,
can_use_masked_phone_numbers: false, can_create_plugins: (business as any).permissions?.can_create_plugins || false,
can_use_pos: false, can_export_data: (business as any).permissions?.can_export_data || false,
can_use_mobile_app: false, can_use_webhooks: (business as any).permissions?.can_use_webhooks || false,
calendar_sync: (business as any).permissions?.calendar_sync || false,
priority_support: (business as any).permissions?.priority_support || false,
dedicated_support: (business as any).permissions?.dedicated_support || false,
sso_enabled: (business as any).permissions?.sso_enabled || false,
}, },
}); });
} }
}, [business]); }, [business]);
// Helper for permission changes
const handlePermissionChange = (key: string, value: boolean) => {
setEditForm(prev => ({
...prev,
permissions: {
...prev.permissions,
[key]: value,
},
}));
};
const handleEditSave = () => { const handleEditSave = () => {
if (!business) return; if (!business) return;
@@ -90,7 +270,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
@@ -141,12 +321,22 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
{/* Subscription Tier */} {/* Subscription Tier */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <div className="flex items-center justify-between mb-1">
Subscription Tier <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
</label> Subscription Tier
</label>
<button
type="button"
onClick={handleResetToTierDefaults}
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300"
>
<RefreshCw size={12} />
Reset to tier defaults
</button>
</div>
<select <select
value={editForm.subscription_tier} value={editForm.subscription_tier}
onChange={(e) => setEditForm({ ...editForm, subscription_tier: e.target.value })} onChange={(e) => handleTierChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
> >
<option value="FREE">Free Trial</option> <option value="FREE">Free Trial</option>
@@ -154,303 +344,312 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
<option value="PROFESSIONAL">Professional</option> <option value="PROFESSIONAL">Professional</option>
<option value="ENTERPRISE">Enterprise</option> <option value="ENTERPRISE">Enterprise</option>
</select> </select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Changing tier will auto-update limits and permissions to tier defaults
</p>
</div> </div>
{/* Limits */} {/* Limits Configuration */}
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div> <h3 className="text-sm font-medium text-gray-900 dark:text-white">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Limits Configuration
Max Users </h3>
</label> <p className="text-xs text-gray-500 dark:text-gray-400">
<input Use -1 for unlimited. These limits control what this business can create.
type="number" </p>
min="1" <div className="grid grid-cols-3 gap-4">
value={editForm.max_users} <div>
onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 1 })} <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" Max Users
/> </label>
</div> <input
<div> type="number"
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> min="-1"
Max Resources value={editForm.max_users}
</label> onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 0 })}
<input className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
type="number" />
min="1" </div>
value={editForm.max_resources} <div>
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 1 })} <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" Max Resources
/> </label>
<input
type="number"
min="-1"
value={editForm.max_resources}
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Services
</label>
<input
type="number"
min="-1"
value={editForm.max_services}
onChange={(e) => setEditForm({ ...editForm, max_services: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Appointments / Month
</label>
<input
type="number"
min="-1"
value={editForm.max_appointments}
onChange={(e) => setEditForm({ ...editForm, max_appointments: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Email Templates
</label>
<input
type="number"
min="-1"
value={editForm.max_email_templates}
onChange={(e) => setEditForm({ ...editForm, max_email_templates: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Automated Tasks
</label>
<input
type="number"
min="-1"
value={editForm.max_automated_tasks}
onChange={(e) => setEditForm({ ...editForm, max_automated_tasks: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div> </div>
</div> </div>
{/* Permissions Section */} {/* Features & Permissions */}
<div className="pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2"> <h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" /> <Key size={16} className="text-purple-500" />
Platform Permissions Features & Permissions
</h4> </h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available to this business.
</p>
<div className="space-y-3"> {/* Payments & Revenue */}
{/* Can Manage OAuth Credentials */} <div>
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
<div> <div className="grid grid-cols-3 gap-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
Manage OAuth Credentials <input
</label> type="checkbox"
<p className="text-xs text-gray-500 dark:text-gray-400"> checked={editForm.can_accept_payments}
Allow this business to configure their own OAuth app credentials onChange={(e) => setEditForm({ ...editForm, can_accept_payments: e.target.checked })}
</p> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
</div> />
<button <span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
type="button" </label>
onClick={() => setEditForm({ ...editForm, can_manage_oauth_credentials: !editForm.can_manage_oauth_credentials })} <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
className={`${editForm.can_manage_oauth_credentials ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`} <input
role="switch" type="checkbox"
> checked={editForm.permissions.can_process_refunds}
<span className={`${editForm.can_manage_oauth_credentials ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} /> onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
</button> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.can_create_packages}
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
</label>
</div> </div>
</div>
{/* Can Accept Payments */} {/* Communication */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <div>
<div> <h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <div className="grid grid-cols-3 gap-3">
Accept Online Payments <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
</label> <input
<p className="text-xs text-gray-500 dark:text-gray-400"> type="checkbox"
Enable Stripe Connect for payment processing checked={editForm.permissions.sms_reminders}
</p> onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
</div> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
<button />
type="button" <span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
onClick={() => setEditForm({ ...editForm, can_accept_payments: !editForm.can_accept_payments })} </label>
className={`${editForm.can_accept_payments ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`} <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
role="switch" <input
> type="checkbox"
<span className={`${editForm.can_accept_payments ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} /> checked={editForm.permissions.can_use_masked_phone_numbers}
</button> onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.can_use_email_templates}
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
</label>
</div> </div>
</div>
{/* Can Use Custom Domain */} {/* Customization */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <div>
<div> <h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <div className="grid grid-cols-3 gap-3">
Use Custom Domain <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
</label> <input
<p className="text-xs text-gray-500 dark:text-gray-400"> type="checkbox"
Allow custom domain configuration checked={editForm.permissions.can_customize_booking_page}
</p> onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
</div> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
<button />
type="button" <span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
onClick={() => setEditForm({ ...editForm, can_use_custom_domain: !editForm.can_use_custom_domain })} </label>
className={`${editForm.can_use_custom_domain ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`} <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
role="switch" <input
> type="checkbox"
<span className={`${editForm.can_use_custom_domain ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} /> checked={editForm.can_use_custom_domain}
</button> onChange={(e) => setEditForm({ ...editForm, can_use_custom_domain: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_white_label}
onChange={(e) => setEditForm({ ...editForm, can_white_label: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
</div> </div>
</div>
{/* Can White Label */} {/* Advanced Features */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <div>
<div> <h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <div className="grid grid-cols-3 gap-3">
Remove Branding <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
</label> <input
<p className="text-xs text-gray-500 dark:text-gray-400"> type="checkbox"
Allow removal of SmoothSchedule branding checked={editForm.permissions.advanced_reporting}
</p> onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
</div> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
<button />
type="button" <span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
onClick={() => setEditForm({ ...editForm, can_white_label: !editForm.can_white_label })} </label>
className={`${editForm.can_white_label ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`} <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
role="switch" <input
> type="checkbox"
<span className={`${editForm.can_white_label ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} /> checked={editForm.can_api_access}
</button> onChange={(e) => setEditForm({ ...editForm, can_api_access: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.can_create_plugins}
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.can_export_data}
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.can_use_webhooks}
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.calendar_sync}
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
</label>
</div> </div>
</div>
{/* Can API Access */} {/* Support & Enterprise */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg"> <div>
<div> <h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <div className="grid grid-cols-3 gap-3">
API Access <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
</label> <input
<p className="text-xs text-gray-500 dark:text-gray-400"> type="checkbox"
Enable API access for integrations checked={editForm.can_manage_oauth_credentials}
</p> onChange={(e) => setEditForm({ ...editForm, can_manage_oauth_credentials: e.target.checked })}
</div> className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
<button />
type="button" <span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
onClick={() => setEditForm({ ...editForm, can_api_access: !editForm.can_api_access })} </label>
className={`${editForm.can_api_access ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`} <label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
role="switch" <input
> type="checkbox"
<span className={`${editForm.can_api_access ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} /> checked={editForm.permissions.priority_support}
</button> onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.dedicated_support}
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.permissions.sso_enabled}
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
</label>
</div> </div>
</div> </div>
</div> </div>
{/* Feature Limits (Not Yet Implemented) */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Feature Limits & Capabilities
</label>
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
Coming Soon
</span>
</div>
<div className="space-y-3 opacity-50">
{/* Video Conferencing */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_add_video_conferencing}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can add video conferencing to events
</label>
{/* Event Types Limit */}
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={editForm.limits.max_event_types === null}
disabled
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited event types</span>
</div>
<input
type="number"
min="1"
disabled
value={editForm.limits.max_event_types || ''}
placeholder="Or set a limit"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
/>
</div>
</div>
{/* Calendars Connected Limit */}
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={editForm.limits.max_calendars_connected === null}
disabled
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited calendar connections</span>
</div>
<input
type="number"
min="1"
disabled
value={editForm.limits.max_calendars_connected || ''}
placeholder="Or set a limit"
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
/>
</div>
</div>
{/* API Access */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_connect_to_api}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can connect to external APIs
</label>
{/* Repeated Events */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_book_repeated_events}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can book repeated/recurring events
</label>
{/* 2FA */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_require_2fa}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can require 2FA for users
</label>
{/* Download Logs */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_download_logs}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can download system logs
</label>
{/* Delete Data */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_delete_data}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can permanently delete data
</label>
{/* Masked Phone Numbers */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_use_masked_phone_numbers}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can use masked phone numbers for privacy
</label>
{/* POS Integration */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_use_pos}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can use Point of Sale (POS) system
</label>
{/* Mobile App */}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={editForm.limits.can_use_mobile_app}
disabled
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
/>
Can use mobile app
</label>
</div>
</div>
</div> </div>
{/* Modal Footer */} {/* Modal Footer */}

View File

@@ -0,0 +1,467 @@
/**
* Quota Settings Page
*
* Manage quota overages by selecting which resources to archive.
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext, Link } from 'react-router-dom';
import {
AlertTriangle, Archive, Check, ChevronDown, ChevronUp,
Clock, Download, Users, Briefcase, Calendar, RefreshCw
} from 'lucide-react';
import { Business, User, QuotaOverage } from '../../types';
import {
getQuotaStatus,
getQuotaResources,
archiveResources,
QuotaStatus,
QuotaResource
} from '../../api/quota';
const QuotaSettings: React.FC = () => {
const { t } = useTranslation();
const { business, user } = useOutletContext<{
business: Business;
user: User;
}>();
const [quotaStatus, setQuotaStatus] = useState<QuotaStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedOverage, setExpandedOverage] = useState<number | null>(null);
const [resources, setResources] = useState<Record<string, QuotaResource[]>>({});
const [selectedResources, setSelectedResources] = useState<Record<string, Set<number>>>({});
const [archiving, setArchiving] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const isOwner = user.role === 'owner';
const isManager = user.role === 'manager';
useEffect(() => {
loadQuotaStatus();
}, []);
const loadQuotaStatus = async () => {
try {
setLoading(true);
setError(null);
const status = await getQuotaStatus();
setQuotaStatus(status);
// Auto-expand first overage if any
if (status.active_overages.length > 0) {
setExpandedOverage(status.active_overages[0].id);
await loadResources(status.active_overages[0].quota_type);
}
} catch (err) {
setError('Failed to load quota status');
console.error(err);
} finally {
setLoading(false);
}
};
const loadResources = async (quotaType: string) => {
if (resources[quotaType]) return; // Already loaded
try {
const response = await getQuotaResources(quotaType);
setResources(prev => ({
...prev,
[quotaType]: response.resources
}));
} catch (err) {
console.error('Failed to load resources:', err);
}
};
const toggleOverage = async (overage: QuotaOverage) => {
if (expandedOverage === overage.id) {
setExpandedOverage(null);
} else {
setExpandedOverage(overage.id);
await loadResources(overage.quota_type);
}
};
const toggleResourceSelection = (quotaType: string, resourceId: number) => {
setSelectedResources(prev => {
const current = prev[quotaType] || new Set();
const newSet = new Set(current);
if (newSet.has(resourceId)) {
newSet.delete(resourceId);
} else {
newSet.add(resourceId);
}
return { ...prev, [quotaType]: newSet };
});
};
const handleArchive = async (quotaType: string) => {
const selected = selectedResources[quotaType];
if (!selected || selected.size === 0) return;
try {
setArchiving(true);
const result = await archiveResources(quotaType, Array.from(selected));
// Clear selection and reload
setSelectedResources(prev => ({ ...prev, [quotaType]: new Set() }));
setResources(prev => {
const { [quotaType]: _, ...rest } = prev;
return rest;
});
if (result.is_resolved) {
setSuccessMessage(t('quota.page.resolved', 'Resolved! Your usage is now within limits.'));
setTimeout(() => setSuccessMessage(null), 5000);
}
await loadQuotaStatus();
} catch (err) {
setError('Failed to archive resources');
console.error(err);
} finally {
setArchiving(false);
}
};
const getQuotaIcon = (quotaType: string) => {
switch (quotaType) {
case 'MAX_ADDITIONAL_USERS':
return <Users className="h-5 w-5" />;
case 'MAX_RESOURCES':
return <Briefcase className="h-5 w-5" />;
case 'MAX_SERVICES':
return <Calendar className="h-5 w-5" />;
default:
return <AlertTriangle className="h-5 w-5" />;
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
if (!isOwner && !isManager) {
return (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">
Only business owners and managers can access quota settings.
</p>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600 dark:text-red-400">{error}</p>
<button
onClick={loadQuotaStatus}
className="mt-4 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('common.reload', 'Reload')}
</button>
</div>
);
}
const hasOverages = quotaStatus && quotaStatus.active_overages.length > 0;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<AlertTriangle className="text-amber-500" />
{t('quota.page.title', 'Quota Management')}
</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">
{t('quota.page.subtitle', 'Manage your account limits and usage')}
</p>
</div>
{/* Success Message */}
{successMessage && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<Check className="h-5 w-5 text-green-600" />
<span className="text-green-800">{successMessage}</span>
</div>
)}
{/* No overages */}
{!hasOverages && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
<Check className="h-12 w-12 text-green-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-green-800 dark:text-green-200">
{t('quota.page.noOverages', 'You are within your plan limits.')}
</h3>
<p className="text-green-600 dark:text-green-400 mt-2">
All your resources are within the limits of your current plan.
</p>
</div>
)}
{/* Usage Overview */}
{quotaStatus && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
Current Usage
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(quotaStatus.usage).map(([quotaType, usage]) => {
const isOver = usage.limit > 0 && usage.current > usage.limit;
return (
<div
key={quotaType}
className={`p-4 rounded-lg border ${
isOver
? 'border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-900/20'
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-700/50'
}`}
>
<div className="flex items-center gap-2 mb-2">
{getQuotaIcon(quotaType)}
<span className="font-medium text-gray-900 dark:text-white">
{usage.display_name}
</span>
</div>
<div className="text-2xl font-bold">
<span className={isOver ? 'text-red-600' : 'text-gray-900 dark:text-white'}>
{usage.current}
</span>
<span className="text-gray-400 text-lg">
{' / '}
{usage.limit < 0 ? 'Unlimited' : usage.limit}
</span>
</div>
{isOver && (
<p className="text-sm text-red-600 mt-1">
Over by {usage.current - usage.limit}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* Active Overages */}
{hasOverages && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Active Overages
</h3>
{quotaStatus!.active_overages.map((overage) => (
<div
key={overage.id}
className="bg-white dark:bg-gray-800 rounded-lg border border-amber-300 dark:border-amber-700 overflow-hidden"
>
{/* Overage Header */}
<button
onClick={() => toggleOverage(overage)}
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${
overage.days_remaining <= 1
? 'bg-red-100 text-red-600'
: overage.days_remaining <= 7
? 'bg-amber-100 text-amber-600'
: 'bg-amber-50 text-amber-500'
}`}>
{getQuotaIcon(overage.quota_type)}
</div>
<div className="text-left">
<h4 className="font-medium text-gray-900 dark:text-white">
{overage.display_name}
</h4>
<p className="text-sm text-gray-500">
{overage.current_usage} / {overage.allowed_limit}
{' • '}
<span className="text-red-600 font-medium">
{overage.overage_amount} over limit
</span>
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
overage.days_remaining <= 1
? 'bg-red-100 text-red-700'
: overage.days_remaining <= 7
? 'bg-amber-100 text-amber-700'
: 'bg-amber-50 text-amber-600'
}`}>
<Clock className="h-4 w-4 inline mr-1" />
{overage.days_remaining} days left
</div>
{expandedOverage === overage.id ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</div>
</button>
{/* Expanded Content */}
{expandedOverage === overage.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200">
<AlertTriangle className="h-4 w-4 inline mr-1" />
{t('quota.page.autoArchiveWarning',
'After the grace period ({{date}}), the oldest {{count}} {{type}} will be automatically archived.',
{
date: formatDate(overage.grace_period_ends_at),
count: overage.overage_amount,
type: overage.display_name
}
)}
</p>
</div>
<h5 className="font-medium text-gray-900 dark:text-white mb-3">
{t('quota.page.selectToArchive', 'Select items to archive')}
</h5>
{/* Resource List */}
{resources[overage.quota_type] ? (
<div className="space-y-2 max-h-64 overflow-y-auto">
{resources[overage.quota_type]
.filter(r => !r.is_archived)
.map((resource) => (
<label
key={resource.id}
className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedResources[overage.quota_type]?.has(resource.id) || false}
onChange={() => toggleResourceSelection(overage.quota_type, resource.id)}
className="h-4 w-4 text-brand-600 rounded border-gray-300 focus:ring-brand-500"
/>
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">
{resource.name}
</p>
{resource.email && (
<p className="text-sm text-gray-500">{resource.email}</p>
)}
{resource.role && (
<p className="text-sm text-gray-500">{resource.role}</p>
)}
{resource.type && (
<p className="text-sm text-gray-500">{resource.type}</p>
)}
</div>
{resource.created_at && (
<span className="text-xs text-gray-400">
Created {formatDate(resource.created_at)}
</span>
)}
</label>
))}
</div>
) : (
<div className="flex items-center justify-center py-4">
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
</div>
)}
{/* Already Archived */}
{resources[overage.quota_type]?.some(r => r.is_archived) && (
<div className="mt-4">
<h6 className="text-sm font-medium text-gray-500 mb-2">Already Archived</h6>
<div className="space-y-2">
{resources[overage.quota_type]
.filter(r => r.is_archived)
.map((resource) => (
<div
key={resource.id}
className="flex items-center gap-3 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50"
>
<Archive className="h-4 w-4 text-gray-400" />
<span className="text-gray-500">{resource.name}</span>
{resource.archived_at && (
<span className="text-xs text-gray-400 ml-auto">
Archived {formatDate(resource.archived_at)}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-4 flex items-center gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => handleArchive(overage.quota_type)}
disabled={
archiving ||
!selectedResources[overage.quota_type] ||
selectedResources[overage.quota_type].size === 0
}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{archiving ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Archive className="h-4 w-4" />
)}
{t('quota.page.archiveSelected', 'Archive Selected')}
{selectedResources[overage.quota_type]?.size > 0 && (
<span className="bg-amber-700 px-2 py-0.5 rounded-full text-xs">
{selectedResources[overage.quota_type].size}
</span>
)}
</button>
<Link
to="/settings/billing"
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
>
{t('quota.page.upgradeInstead', 'Upgrade Plan Instead')}
</Link>
<button className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
<Download className="h-4 w-4" />
{t('quota.page.exportData', 'Export Data')}
</button>
</div>
{/* Archive Warning */}
<p className="mt-3 text-sm text-gray-500">
<AlertTriangle className="h-4 w-4 inline mr-1" />
{t('quota.page.archiveWarning',
'Archived items will become read-only and cannot be used for new bookings.'
)}
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default QuotaSettings;

View File

@@ -22,3 +22,6 @@ export { default as CommunicationSettings } from './CommunicationSettings';
// Billing // Billing
export { default as BillingSettings } from './BillingSettings'; export { default as BillingSettings } from './BillingSettings';
// Quota Management
export { default as QuotaSettings } from './QuotaSettings';

View File

@@ -93,6 +93,17 @@ export interface NotificationPreferences {
marketing: boolean; marketing: boolean;
} }
export interface QuotaOverage {
id: number;
quota_type: string;
display_name: string;
current_usage: number;
allowed_limit: number;
overage_amount: number;
days_remaining: number;
grace_period_ends_at: string;
}
export interface User { export interface User {
id: string | number; id: string | number;
username?: string; username?: string;
@@ -109,6 +120,7 @@ export interface User {
can_invite_staff?: boolean; can_invite_staff?: boolean;
can_access_tickets?: boolean; can_access_tickets?: boolean;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
quota_overages?: QuotaOverage[];
} }
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT'; export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';

View File

@@ -279,6 +279,28 @@ CELERY_TASK_TIME_LIMIT = 5 * 60
CELERY_TASK_SOFT_TIME_LIMIT = 60 CELERY_TASK_SOFT_TIME_LIMIT = 60
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler" CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Celery Beat Schedule (for reference - actual schedule managed in database)
# These tasks are created via data migration in core app
# CELERY_BEAT_SCHEDULE = {
# 'quota-check-all-tenants': {
# 'task': 'core.tasks.check_all_tenant_quotas',
# 'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
# },
# 'quota-send-reminders': {
# 'task': 'core.tasks.send_quota_reminder_emails',
# 'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
# },
# 'quota-process-expired': {
# 'task': 'core.tasks.process_expired_quotas',
# 'schedule': crontab(hour=3, minute=0), # Daily at 3 AM
# },
# 'quota-cleanup-old': {
# 'task': 'core.tasks.cleanup_old_resolved_overages',
# 'schedule': crontab(day_of_week=0, hour=4, minute=0), # Weekly on Sunday 4 AM
# },
# }
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events # https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events
CELERY_WORKER_SEND_TASK_EVENTS = True CELERY_WORKER_SEND_TASK_EVENTS = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event # https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event

View File

@@ -36,6 +36,13 @@ from core.email_autoconfig import (
AppleConfigProfileView, AppleConfigProfileView,
WellKnownAutoconfigView, WellKnownAutoconfigView,
) )
from core.api_views import (
quota_status_view,
quota_resources_view,
quota_archive_view,
quota_unarchive_view,
quota_overage_detail_view,
)
urlpatterns = [ urlpatterns = [
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
@@ -115,6 +122,12 @@ urlpatterns += [
path("sandbox/status/", sandbox_status_view, name="sandbox_status"), path("sandbox/status/", sandbox_status_view, name="sandbox_status"),
path("sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"), path("sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"),
path("sandbox/reset/", sandbox_reset_view, name="sandbox_reset"), path("sandbox/reset/", sandbox_reset_view, name="sandbox_reset"),
# Quota Management API
path("quota/status/", quota_status_view, name="quota_status"),
path("quota/resources/<str:quota_type>/", quota_resources_view, name="quota_resources"),
path("quota/archive/", quota_archive_view, name="quota_archive"),
path("quota/unarchive/", quota_unarchive_view, name="quota_unarchive"),
path("quota/overages/<int:overage_id>/", quota_overage_detail_view, name="quota_overage_detail"),
# MFA (Two-Factor Authentication) API # MFA (Two-Factor Authentication) API
path("auth/mfa/status/", mfa_status, name="mfa_status"), path("auth/mfa/status/", mfa_status, name="mfa_status"),
path("auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"), path("auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"),

View File

@@ -0,0 +1,317 @@
"""
API views for quota management.
"""
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .quota_service import QuotaService
from .models import QuotaOverage
from smoothschedule.users.models import User
def is_owner_or_manager(user):
"""Check if user is a tenant owner or manager."""
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def quota_status_view(request):
"""
Get current quota status for the user's tenant.
GET /api/quota/status/
Returns:
- active_overages: List of active quota overages
- usage: Current usage for each quota type
"""
user = request.user
if not user.tenant:
return Response(
{'error': 'No tenant associated with this user'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_owner_or_manager(user):
return Response(
{'error': 'Only owners and managers can view quota status'},
status=status.HTTP_403_FORBIDDEN
)
service = QuotaService(user.tenant)
# Get active overages
overages = service.get_active_overages()
# Get current usage for all quota types
usage = {}
for quota_type, config in service.QUOTA_CONFIG.items():
usage[quota_type] = {
'current': service.get_current_usage(quota_type),
'limit': service.get_limit(quota_type),
'display_name': config['display_name'],
}
return Response({
'active_overages': overages,
'usage': usage,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def quota_resources_view(request, quota_type):
"""
Get list of resources that can be archived for a specific quota type.
GET /api/quota/resources/<quota_type>/
Returns list of resources with their details and whether they're archived.
"""
user = request.user
if not user.tenant:
return Response(
{'error': 'No tenant associated with this user'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_owner_or_manager(user):
return Response(
{'error': 'Only owners and managers can view quota resources'},
status=status.HTTP_403_FORBIDDEN
)
resources = []
if quota_type == 'MAX_ADDITIONAL_USERS':
# Get users excluding owner
users = User.objects.filter(
tenant=user.tenant
).exclude(
role=User.Role.TENANT_OWNER
).order_by('date_joined')
for u in users:
resources.append({
'id': u.id,
'name': u.get_full_name() or u.username,
'email': u.email,
'role': u.get_role_display(),
'created_at': u.date_joined.isoformat(),
'is_archived': u.is_archived_by_quota,
'archived_at': u.archived_by_quota_at.isoformat() if u.archived_by_quota_at else None,
})
elif quota_type == 'MAX_RESOURCES':
from schedule.models import Resource
for r in Resource.objects.all().order_by('created_at'):
resources.append({
'id': r.id,
'name': r.name,
'type': r.get_type_display() if hasattr(r, 'get_type_display') else r.type,
'created_at': r.created_at.isoformat() if hasattr(r, 'created_at') else None,
'is_archived': r.is_archived_by_quota,
'archived_at': r.archived_by_quota_at.isoformat() if r.archived_by_quota_at else None,
})
elif quota_type == 'MAX_SERVICES':
from schedule.models import Service
for s in Service.objects.all().order_by('created_at'):
resources.append({
'id': s.id,
'name': s.name,
'duration': s.duration,
'price': str(s.price) if s.price else None,
'created_at': s.created_at.isoformat() if hasattr(s, 'created_at') else None,
'is_archived': s.is_archived_by_quota,
'archived_at': s.archived_by_quota_at.isoformat() if s.archived_by_quota_at else None,
})
else:
return Response(
{'error': f'Unknown quota type: {quota_type}'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({
'quota_type': quota_type,
'resources': resources,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def quota_archive_view(request):
"""
Archive selected resources to resolve quota overage.
POST /api/quota/archive/
Body:
- quota_type: The quota type (e.g., 'MAX_ADDITIONAL_USERS')
- resource_ids: List of resource IDs to archive
"""
user = request.user
if not user.tenant:
return Response(
{'error': 'No tenant associated with this user'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_owner_or_manager(user):
return Response(
{'error': 'Only owners and managers can archive resources'},
status=status.HTTP_403_FORBIDDEN
)
quota_type = request.data.get('quota_type')
resource_ids = request.data.get('resource_ids', [])
if not quota_type:
return Response(
{'error': 'quota_type is required'},
status=status.HTTP_400_BAD_REQUEST
)
if not resource_ids:
return Response(
{'error': 'resource_ids is required'},
status=status.HTTP_400_BAD_REQUEST
)
service = QuotaService(user.tenant)
# Verify quota type is valid
if quota_type not in service.QUOTA_CONFIG:
return Response(
{'error': f'Unknown quota type: {quota_type}'},
status=status.HTTP_400_BAD_REQUEST
)
# Archive the resources
archived_count = service.archive_resources(quota_type, resource_ids)
# Check if overage is now resolved
current_usage = service.get_current_usage(quota_type)
limit = service.get_limit(quota_type)
is_resolved = current_usage <= limit
return Response({
'archived_count': archived_count,
'current_usage': current_usage,
'limit': limit,
'is_resolved': is_resolved,
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def quota_unarchive_view(request):
"""
Unarchive a resource (only if there's room in the quota).
POST /api/quota/unarchive/
Body:
- quota_type: The quota type
- resource_id: The resource ID to unarchive
"""
user = request.user
if not user.tenant:
return Response(
{'error': 'No tenant associated with this user'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_owner_or_manager(user):
return Response(
{'error': 'Only owners and managers can unarchive resources'},
status=status.HTTP_403_FORBIDDEN
)
quota_type = request.data.get('quota_type')
resource_id = request.data.get('resource_id')
if not quota_type or not resource_id:
return Response(
{'error': 'quota_type and resource_id are required'},
status=status.HTTP_400_BAD_REQUEST
)
service = QuotaService(user.tenant)
# Try to unarchive
success = service.unarchive_resource(quota_type, resource_id)
if not success:
return Response(
{'error': 'Cannot unarchive: quota limit would be exceeded'},
status=status.HTTP_400_BAD_REQUEST
)
return Response({
'success': True,
'resource_id': resource_id,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def quota_overage_detail_view(request, overage_id):
"""
Get details for a specific quota overage.
GET /api/quota/overages/<overage_id>/
"""
user = request.user
if not user.tenant:
return Response(
{'error': 'No tenant associated with this user'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_owner_or_manager(user):
return Response(
{'error': 'Only owners and managers can view overage details'},
status=status.HTTP_403_FORBIDDEN
)
try:
overage = QuotaOverage.objects.get(
id=overage_id,
tenant=user.tenant
)
except QuotaOverage.DoesNotExist:
return Response(
{'error': 'Overage not found'},
status=status.HTTP_404_NOT_FOUND
)
service = QuotaService(user.tenant)
config = service.QUOTA_CONFIG.get(overage.quota_type, {})
return Response({
'id': overage.id,
'quota_type': overage.quota_type,
'display_name': config.get('display_name', overage.quota_type),
'status': overage.status,
'current_usage': overage.current_usage,
'allowed_limit': overage.allowed_limit,
'overage_amount': overage.overage_amount,
'days_remaining': overage.days_remaining,
'grace_period_ends_at': overage.grace_period_ends_at.isoformat() if overage.grace_period_ends_at else None,
'created_at': overage.created_at.isoformat(),
'initial_email_sent_at': overage.initial_email_sent_at.isoformat() if overage.initial_email_sent_at else None,
'week_reminder_sent_at': overage.week_reminder_sent_at.isoformat() if overage.week_reminder_sent_at else None,
'day_reminder_sent_at': overage.day_reminder_sent_at.isoformat() if overage.day_reminder_sent_at else None,
'archived_resource_ids': overage.archived_resource_ids,
})

View File

@@ -0,0 +1,102 @@
"""
Management command to set up periodic Celery tasks for quota management.
Run this after deployment:
python manage.py setup_quota_tasks
"""
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Set up periodic Celery Beat tasks for quota management'
def handle(self, *args, **options):
from django_celery_beat.models import PeriodicTask, CrontabSchedule
self.stdout.write('Setting up quota management periodic tasks...')
# Create crontab schedules
# Daily at 2 AM - check all tenants for quota overages
schedule_2am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='2',
day_of_week='*',
day_of_month='*',
month_of_year='*',
)
# Daily at 3 AM - process expired grace periods
schedule_3am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='3',
day_of_week='*',
day_of_month='*',
month_of_year='*',
)
# Daily at 8 AM - send reminder emails
schedule_8am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='8',
day_of_week='*',
day_of_month='*',
month_of_year='*',
)
# Weekly on Sunday at 4 AM - cleanup old overage records
schedule_sunday_4am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='4',
day_of_week='0', # Sunday
day_of_month='*',
month_of_year='*',
)
# Create periodic tasks
tasks = [
{
'name': 'quota-check-all-tenants',
'task': 'core.tasks.check_all_tenant_quotas',
'crontab': schedule_2am,
'description': 'Check all tenants for quota overages (runs daily at 2 AM)',
},
{
'name': 'quota-process-expired',
'task': 'core.tasks.process_expired_quotas',
'crontab': schedule_3am,
'description': 'Auto-archive resources for expired grace periods (runs daily at 3 AM)',
},
{
'name': 'quota-send-reminders',
'task': 'core.tasks.send_quota_reminder_emails',
'crontab': schedule_8am,
'description': 'Send quota overage reminder emails (runs daily at 8 AM)',
},
{
'name': 'quota-cleanup-old',
'task': 'core.tasks.cleanup_old_resolved_overages',
'crontab': schedule_sunday_4am,
'description': 'Clean up old resolved quota overage records (runs weekly on Sunday at 4 AM)',
},
]
for task_config in tasks:
task, created = PeriodicTask.objects.update_or_create(
name=task_config['name'],
defaults={
'task': task_config['task'],
'crontab': task_config['crontab'],
'description': task_config['description'],
'enabled': True,
}
)
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
self.stdout.write(self.style.SUCCESS('\nQuota management tasks set up successfully!'))
self.stdout.write('\nTasks configured:')
self.stdout.write(' - quota-check-all-tenants: Daily at 2 AM')
self.stdout.write(' - quota-process-expired: Daily at 3 AM')
self.stdout.write(' - quota-send-reminders: Daily at 8 AM')
self.stdout.write(' - quota-cleanup-old: Weekly on Sunday at 4 AM')

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.8 on 2025-12-02 16:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_tenant_can_use_calendar_sync'),
]
operations = [
migrations.AlterField(
model_name='tierlimit',
name='feature_code',
field=models.CharField(help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')", max_length=100),
),
migrations.CreateModel(
name='QuotaOverage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quota_type', models.CharField(choices=[('MAX_ADDITIONAL_USERS', 'Additional Users'), ('MAX_RESOURCES', 'Resources'), ('MAX_SERVICES', 'Services'), ('MAX_EMAIL_TEMPLATES', 'Email Templates'), ('MAX_AUTOMATED_TASKS', 'Automated Tasks')], help_text='Which quota limit was exceeded', max_length=50)),
('status', models.CharField(choices=[('ACTIVE', 'Active - Grace period in effect'), ('RESOLVED', 'Resolved - User reduced usage or upgraded'), ('ARCHIVED', 'Archived - Grace period expired, resources archived'), ('CANCELLED', 'Cancelled - Admin intervention')], default='ACTIVE', max_length=20)),
('current_usage', models.IntegerField(help_text='Usage count when overage was detected')),
('allowed_limit', models.IntegerField(help_text='New limit after plan change')),
('overage_amount', models.IntegerField(help_text='Number of items over the limit (usage - limit)')),
('grace_period_days', models.IntegerField(default=30, help_text='Number of days before auto-archive')),
('detected_at', models.DateTimeField(auto_now_add=True, help_text='When the overage was first detected')),
('grace_period_ends_at', models.DateTimeField(help_text='When the grace period expires')),
('initial_email_sent_at', models.DateTimeField(blank=True, help_text='When the initial overage notification was sent', null=True)),
('week_reminder_sent_at', models.DateTimeField(blank=True, help_text='When the 7-day warning was sent', null=True)),
('day_reminder_sent_at', models.DateTimeField(blank=True, help_text='When the 1-day warning was sent', null=True)),
('resolved_at', models.DateTimeField(blank=True, help_text='When the overage was resolved', null=True)),
('resolution_method', models.CharField(blank=True, choices=[('USER_ARCHIVED', 'User selected resources to archive'), ('USER_DELETED', 'User deleted excess resources'), ('USER_UPGRADED', 'User upgraded their plan'), ('AUTO_ARCHIVED', 'Auto-archived after grace period'), ('ADMIN_RESOLVED', 'Resolved by admin')], max_length=50, null=True)),
('archived_resource_ids', models.JSONField(blank=True, default=list, help_text='IDs of resources that were archived due to this overage')),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_overages', to='core.tenant')),
],
options={
'ordering': ['-detected_at'],
'indexes': [models.Index(fields=['tenant', 'status'], name='core_quotao_tenant__5f1a84_idx'), models.Index(fields=['grace_period_ends_at', 'status'], name='core_quotao_grace_p_8a39bd_idx')],
},
),
]

View File

@@ -774,21 +774,176 @@ class TierLimit(models.Model):
('ENTERPRISE', 'Enterprise'), ('ENTERPRISE', 'Enterprise'),
] ]
) )
feature_code = models.CharField( feature_code = models.CharField(
max_length=100, max_length=100,
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')" help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')"
) )
limit = models.IntegerField( limit = models.IntegerField(
default=0, default=0,
help_text="Maximum allowed count for this feature" help_text="Maximum allowed count for this feature"
) )
class Meta: class Meta:
unique_together = ['tier', 'feature_code'] unique_together = ['tier', 'feature_code']
ordering = ['tier', 'feature_code'] ordering = ['tier', 'feature_code']
def __str__(self): def __str__(self):
return f"{self.tier} - {self.feature_code}: {self.limit}" return f"{self.tier} - {self.feature_code}: {self.limit}"
class QuotaOverage(models.Model):
"""
Tracks quota overages when a tenant exceeds their plan limits.
Created when:
- Tenant downgrades their plan
- Tenant's plan expires/lapses to free tier
- Plan limits are reduced administratively
Grace period: 30 days to resolve the overage by:
1. Selecting which resources to archive
2. Upgrading their plan
3. Deleting excess resources
After grace period expires, excess resources are automatically archived.
"""
QUOTA_TYPES = [
('MAX_ADDITIONAL_USERS', 'Additional Users'),
('MAX_RESOURCES', 'Resources'),
('MAX_SERVICES', 'Services'),
('MAX_EMAIL_TEMPLATES', 'Email Templates'),
('MAX_AUTOMATED_TASKS', 'Automated Tasks'),
]
STATUS_CHOICES = [
('ACTIVE', 'Active - Grace period in effect'),
('RESOLVED', 'Resolved - User reduced usage or upgraded'),
('ARCHIVED', 'Archived - Grace period expired, resources archived'),
('CANCELLED', 'Cancelled - Admin intervention'),
]
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='quota_overages'
)
quota_type = models.CharField(
max_length=50,
choices=QUOTA_TYPES,
help_text="Which quota limit was exceeded"
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='ACTIVE'
)
# Snapshot at time of overage
current_usage = models.IntegerField(
help_text="Usage count when overage was detected"
)
allowed_limit = models.IntegerField(
help_text="New limit after plan change"
)
overage_amount = models.IntegerField(
help_text="Number of items over the limit (usage - limit)"
)
# Grace period tracking
grace_period_days = models.IntegerField(
default=30,
help_text="Number of days before auto-archive"
)
detected_at = models.DateTimeField(
auto_now_add=True,
help_text="When the overage was first detected"
)
grace_period_ends_at = models.DateTimeField(
help_text="When the grace period expires"
)
# Notification tracking
initial_email_sent_at = models.DateTimeField(
null=True, blank=True,
help_text="When the initial overage notification was sent"
)
week_reminder_sent_at = models.DateTimeField(
null=True, blank=True,
help_text="When the 7-day warning was sent"
)
day_reminder_sent_at = models.DateTimeField(
null=True, blank=True,
help_text="When the 1-day warning was sent"
)
# Resolution tracking
resolved_at = models.DateTimeField(
null=True, blank=True,
help_text="When the overage was resolved"
)
resolution_method = models.CharField(
max_length=50,
null=True, blank=True,
choices=[
('USER_ARCHIVED', 'User selected resources to archive'),
('USER_DELETED', 'User deleted excess resources'),
('USER_UPGRADED', 'User upgraded their plan'),
('AUTO_ARCHIVED', 'Auto-archived after grace period'),
('ADMIN_RESOLVED', 'Resolved by admin'),
]
)
# JSON field to track which resources were archived
archived_resource_ids = models.JSONField(
default=list,
blank=True,
help_text="IDs of resources that were archived due to this overage"
)
class Meta:
ordering = ['-detected_at']
indexes = [
models.Index(fields=['tenant', 'status']),
models.Index(fields=['grace_period_ends_at', 'status']),
]
def __str__(self):
return f"{self.tenant.name} - {self.get_quota_type_display()} overage ({self.overage_amount} over)"
def save(self, *args, **kwargs):
# Auto-calculate grace period end date on creation
if not self.pk and not self.grace_period_ends_at:
self.grace_period_ends_at = timezone.now() + timedelta(days=self.grace_period_days)
# Auto-calculate overage amount
self.overage_amount = max(0, self.current_usage - self.allowed_limit)
super().save(*args, **kwargs)
@property
def days_remaining(self):
"""Returns the number of days remaining in the grace period."""
if self.status != 'ACTIVE':
return 0
remaining = (self.grace_period_ends_at - timezone.now()).days
return max(0, remaining)
@property
def is_grace_period_expired(self):
"""Check if the grace period has expired."""
return timezone.now() >= self.grace_period_ends_at
def resolve(self, method, archived_ids=None):
"""Mark this overage as resolved."""
self.status = 'RESOLVED' if method != 'AUTO_ARCHIVED' else 'ARCHIVED'
self.resolved_at = timezone.now()
self.resolution_method = method
if archived_ids:
self.archived_resource_ids = archived_ids
self.save()

View File

@@ -198,7 +198,7 @@ def HasQuota(feature_code):
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')] permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
Args: Args:
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS') feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')
Returns: Returns:
QuotaPermission class configured for the feature QuotaPermission class configured for the feature
@@ -218,9 +218,10 @@ def HasQuota(feature_code):
# Map feature codes to model paths for usage counting # Map feature codes to model paths for usage counting
# CRITICAL: This map must be populated for the permission to work # CRITICAL: This map must be populated for the permission to work
# Note: MAX_ADDITIONAL_USERS requires special handling (shared schema + exclude owner)
USAGE_MAP = { USAGE_MAP = {
'MAX_RESOURCES': 'schedule.Resource', 'MAX_RESOURCES': 'schedule.Resource',
'MAX_USERS': 'users.User', 'MAX_ADDITIONAL_USERS': 'users.User', # Renamed from MAX_USERS - excludes owner
'MAX_EVENTS_PER_MONTH': 'schedule.Event', 'MAX_EVENTS_PER_MONTH': 'schedule.Event',
'MAX_SERVICES': 'schedule.Service', 'MAX_SERVICES': 'schedule.Service',
'MAX_APPOINTMENTS': 'schedule.Event', 'MAX_APPOINTMENTS': 'schedule.Event',
@@ -271,10 +272,22 @@ def HasQuota(feature_code):
return True return True
# Count current usage # Count current usage
# NOTE: django-tenants automatically scopes this query to tenant schema # NOTE: Most models use django-tenants automatic scoping
# But User is in shared schema, so needs special handling
# Special handling for additional users (shared schema, exclude owner)
if feature_code == 'MAX_ADDITIONAL_USERS':
from smoothschedule.users.models import User
# Count users in this tenant, excluding the owner and archived users
current_count = User.objects.filter(
tenant=tenant,
is_archived_by_quota=False
).exclude(
role=User.Role.TENANT_OWNER
).count()
# Special handling for monthly appointment limit # Special handling for monthly appointment limit
if feature_code == 'MAX_APPOINTMENTS': elif feature_code == 'MAX_APPOINTMENTS':
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
# Count appointments in current month # Count appointments in current month
@@ -286,8 +299,14 @@ def HasQuota(feature_code):
start_time__gte=start_of_month, start_time__gte=start_of_month,
start_time__lt=start_of_next_month start_time__lt=start_of_next_month
).count() ).count()
# Standard counting for tenant-scoped models
else: else:
current_count = Model.objects.count() # Exclude archived resources from count
if hasattr(Model, 'is_archived_by_quota'):
current_count = Model.objects.filter(is_archived_by_quota=False).count()
else:
current_count = Model.objects.count()
# The "Hard Block": Enforce the limit # The "Hard Block": Enforce the limit
if current_count >= limit: if current_count >= limit:

View File

@@ -0,0 +1,648 @@
"""
Quota Overage Service
Handles detection, tracking, and resolution of quota overages when tenants
exceed their plan limits (e.g., after downgrade or plan expiration).
Grace Period: 30 days
- Users can select which resources to archive
- After grace period, excess resources are auto-archived
- Archived resources become read-only (visible but not usable)
Email Notifications:
- Immediately when overage detected
- 7 days before grace period ends
- 1 day before grace period ends
"""
import logging
from datetime import timedelta
from django.utils import timezone
from django.db import transaction
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from .models import Tenant, QuotaOverage, TierLimit
from smoothschedule.users.models import User
logger = logging.getLogger(__name__)
class QuotaService:
"""
Service class for managing quota overages.
"""
GRACE_PERIOD_DAYS = 30
# Quota types and their corresponding models/counting logic
QUOTA_CONFIG = {
'MAX_ADDITIONAL_USERS': {
'model': 'smoothschedule.users.models.User',
'display_name': 'additional team members',
'count_method': 'count_additional_users',
},
'MAX_RESOURCES': {
'model': 'schedule.models.Resource',
'display_name': 'resources',
'count_method': 'count_resources',
},
'MAX_SERVICES': {
'model': 'schedule.models.Service',
'display_name': 'services',
'count_method': 'count_services',
},
'MAX_EMAIL_TEMPLATES': {
'model': 'schedule.models.EmailTemplate',
'display_name': 'email templates',
'count_method': 'count_email_templates',
},
'MAX_AUTOMATED_TASKS': {
'model': 'schedule.models.ScheduledTask',
'display_name': 'automated tasks',
'count_method': 'count_automated_tasks',
},
}
def __init__(self, tenant: Tenant):
self.tenant = tenant
# =========================================================================
# Counting Methods
# =========================================================================
def count_additional_users(self) -> int:
"""Count additional users (excluding owner and archived)."""
return User.objects.filter(
tenant=self.tenant,
is_archived_by_quota=False
).exclude(
role=User.Role.TENANT_OWNER
).count()
def count_resources(self) -> int:
"""Count active resources (excluding archived)."""
from schedule.models import Resource
return Resource.objects.filter(is_archived_by_quota=False).count()
def count_services(self) -> int:
"""Count active services (excluding archived)."""
from schedule.models import Service
return Service.objects.filter(is_archived_by_quota=False).count()
def count_email_templates(self) -> int:
"""Count email templates."""
from schedule.models import EmailTemplate
return EmailTemplate.objects.count()
def count_automated_tasks(self) -> int:
"""Count automated tasks."""
from schedule.models import ScheduledTask
return ScheduledTask.objects.count()
# =========================================================================
# Limit Retrieval
# =========================================================================
def get_current_usage(self, quota_type: str) -> int:
"""Get the current usage for a quota type."""
config = self.QUOTA_CONFIG.get(quota_type)
if not config:
return 0
count_method = getattr(self, config['count_method'])
return count_method()
def get_limit(self, quota_type: str) -> int:
"""Get the current limit for a quota type based on tenant's plan."""
# First check subscription plan if available
if self.tenant.subscription_plan:
limits = self.tenant.subscription_plan.limits or {}
# Convert quota type to plan limit key (e.g., MAX_ADDITIONAL_USERS -> max_additional_users)
limit_key = quota_type.lower()
if limit_key in limits:
return limits[limit_key]
# Fall back to TierLimit table
try:
tier_limit = TierLimit.objects.get(
tier=self.tenant.subscription_tier,
feature_code=quota_type
)
return tier_limit.limit
except TierLimit.DoesNotExist:
# No limit defined = unlimited
return -1 # -1 means unlimited
# =========================================================================
# Overage Detection
# =========================================================================
def check_all_quotas(self) -> list[QuotaOverage]:
"""
Check all quota types for overages.
Returns list of newly created QuotaOverage records.
"""
new_overages = []
for quota_type, config in self.QUOTA_CONFIG.items():
overage = self.check_quota(quota_type)
if overage:
new_overages.append(overage)
return new_overages
def check_quota(self, quota_type: str) -> QuotaOverage | None:
"""
Check a specific quota type for overage.
Creates QuotaOverage record if over limit and none exists.
Returns the overage record or None.
"""
config = self.QUOTA_CONFIG.get(quota_type)
if not config:
logger.warning(f"Unknown quota type: {quota_type}")
return None
# Get current usage
count_method = getattr(self, config['count_method'])
current_usage = count_method()
# Get limit
limit = self.get_limit(quota_type)
# -1 means unlimited
if limit < 0:
return None
# Check if over limit
if current_usage <= limit:
# Not over limit - check if there's an active overage to resolve
self._resolve_overage_if_exists(quota_type)
return None
# Over limit - check for existing active overage
existing = QuotaOverage.objects.filter(
tenant=self.tenant,
quota_type=quota_type,
status='ACTIVE'
).first()
if existing:
# Update the existing overage with current counts
existing.current_usage = current_usage
existing.allowed_limit = limit
existing.save()
return existing
# Create new overage record
with transaction.atomic():
overage = QuotaOverage.objects.create(
tenant=self.tenant,
quota_type=quota_type,
current_usage=current_usage,
allowed_limit=limit,
overage_amount=current_usage - limit,
grace_period_days=self.GRACE_PERIOD_DAYS,
grace_period_ends_at=timezone.now() + timedelta(days=self.GRACE_PERIOD_DAYS)
)
# Send initial notification email
self.send_overage_notification(overage, 'initial')
logger.info(
f"Created quota overage for {self.tenant.name}: "
f"{quota_type} ({current_usage}/{limit})"
)
return overage
def _resolve_overage_if_exists(self, quota_type: str):
"""Resolve any existing active overage for this quota type."""
existing = QuotaOverage.objects.filter(
tenant=self.tenant,
quota_type=quota_type,
status='ACTIVE'
).first()
if existing:
existing.resolve('USER_DELETED')
logger.info(
f"Resolved quota overage for {self.tenant.name}: {quota_type}"
)
# =========================================================================
# Email Notifications
# =========================================================================
def send_overage_notification(self, overage: QuotaOverage, notification_type: str):
"""
Send email notification about quota overage.
notification_type:
- 'initial': First notification when overage detected
- 'week_reminder': 7 days before grace period ends
- 'day_reminder': 1 day before grace period ends
"""
# Get tenant owner
owner = User.objects.filter(
tenant=self.tenant,
role=User.Role.TENANT_OWNER
).first()
if not owner or not owner.email:
logger.warning(
f"Cannot send overage notification for {self.tenant.name}: no owner email"
)
return
config = self.QUOTA_CONFIG.get(overage.quota_type, {})
display_name = config.get('display_name', overage.quota_type)
# Prepare email context
context = {
'tenant': self.tenant,
'owner': owner,
'overage': overage,
'display_name': display_name,
'days_remaining': overage.days_remaining,
'grace_period_ends': overage.grace_period_ends_at,
'current_usage': overage.current_usage,
'allowed_limit': overage.allowed_limit,
'overage_amount': overage.overage_amount,
'manage_url': self._get_manage_url(),
'upgrade_url': self._get_upgrade_url(),
'export_url': self._get_export_url(),
}
# Select template based on notification type
if notification_type == 'initial':
subject = f"Action Required: Your {self.tenant.name} account has exceeded its quota"
template = 'emails/quota_overage_initial.html'
overage.initial_email_sent_at = timezone.now()
elif notification_type == 'week_reminder':
subject = f"Reminder: 7 days left to resolve quota overage for {self.tenant.name}"
template = 'emails/quota_overage_week_reminder.html'
overage.week_reminder_sent_at = timezone.now()
elif notification_type == 'day_reminder':
subject = f"Final Warning: 1 day left to resolve quota overage for {self.tenant.name}"
template = 'emails/quota_overage_day_reminder.html'
overage.day_reminder_sent_at = timezone.now()
else:
logger.error(f"Unknown notification type: {notification_type}")
return
overage.save()
# Render and send email
try:
html_message = render_to_string(template, context)
text_message = render_to_string(
template.replace('.html', '.txt'),
context
)
send_mail(
subject=subject,
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[owner.email],
html_message=html_message,
fail_silently=False,
)
logger.info(
f"Sent {notification_type} overage email to {owner.email} "
f"for {self.tenant.name}"
)
except Exception as e:
logger.error(
f"Failed to send overage email to {owner.email}: {e}"
)
def _get_manage_url(self) -> str:
"""Get URL for quota management page."""
domain = self.tenant.get_primary_domain()
if domain:
return f"https://{domain.domain}/settings/quota"
return ""
def _get_upgrade_url(self) -> str:
"""Get URL for plan upgrade page."""
domain = self.tenant.get_primary_domain()
if domain:
return f"https://{domain.domain}/settings/subscription"
return ""
def _get_export_url(self) -> str:
"""Get URL for data export page."""
domain = self.tenant.get_primary_domain()
if domain:
return f"https://{domain.domain}/settings/export"
return ""
# =========================================================================
# Resource Archiving
# =========================================================================
def archive_resources(self, quota_type: str, resource_ids: list[int]) -> int:
"""
Archive specific resources selected by the user.
Returns the number of resources archived.
"""
count = 0
if quota_type == 'MAX_ADDITIONAL_USERS':
count = User.objects.filter(
tenant=self.tenant,
id__in=resource_ids,
is_archived_by_quota=False
).exclude(
role=User.Role.TENANT_OWNER # Never archive owner
).update(
is_archived_by_quota=True,
archived_by_quota_at=timezone.now()
)
elif quota_type == 'MAX_RESOURCES':
from schedule.models import Resource
count = Resource.objects.filter(
id__in=resource_ids,
is_archived_by_quota=False
).update(
is_archived_by_quota=True,
archived_by_quota_at=timezone.now()
)
elif quota_type == 'MAX_SERVICES':
from schedule.models import Service
count = Service.objects.filter(
id__in=resource_ids,
is_archived_by_quota=False
).update(
is_archived_by_quota=True,
archived_by_quota_at=timezone.now()
)
# Update overage record
overage = QuotaOverage.objects.filter(
tenant=self.tenant,
quota_type=quota_type,
status='ACTIVE'
).first()
if overage:
# Check if resolved
count_method = getattr(self, self.QUOTA_CONFIG[quota_type]['count_method'])
current_usage = count_method()
if current_usage <= overage.allowed_limit:
overage.resolve('USER_ARCHIVED', resource_ids)
return count
def unarchive_resource(self, quota_type: str, resource_id: int) -> bool:
"""
Unarchive a resource (swap with another that will be archived).
Returns True if successful.
"""
# Check if we have room to unarchive
count_method = getattr(self, self.QUOTA_CONFIG[quota_type]['count_method'])
current_usage = count_method()
limit = self.get_limit(quota_type)
if current_usage >= limit:
# No room - cannot unarchive without archiving another
return False
if quota_type == 'MAX_ADDITIONAL_USERS':
User.objects.filter(
id=resource_id,
tenant=self.tenant
).update(
is_archived_by_quota=False,
archived_by_quota_at=None
)
elif quota_type == 'MAX_RESOURCES':
from schedule.models import Resource
Resource.objects.filter(id=resource_id).update(
is_archived_by_quota=False,
archived_by_quota_at=None
)
elif quota_type == 'MAX_SERVICES':
from schedule.models import Service
Service.objects.filter(id=resource_id).update(
is_archived_by_quota=False,
archived_by_quota_at=None
)
return True
# =========================================================================
# Auto-Archive (Grace Period Expired)
# =========================================================================
def auto_archive_expired(self) -> dict:
"""
Auto-archive resources for overages where grace period has expired.
Archives the oldest/least recently used resources.
Returns dict with counts of archived resources by type.
"""
results = {}
expired_overages = QuotaOverage.objects.filter(
tenant=self.tenant,
status='ACTIVE',
grace_period_ends_at__lte=timezone.now()
)
for overage in expired_overages:
archived_ids = self._auto_archive_for_overage(overage)
if archived_ids:
overage.resolve('AUTO_ARCHIVED', archived_ids)
results[overage.quota_type] = len(archived_ids)
return results
def _auto_archive_for_overage(self, overage: QuotaOverage) -> list[int]:
"""
Auto-archive excess resources for a specific overage.
Archives the oldest resources first.
Returns list of archived resource IDs.
"""
quota_type = overage.quota_type
excess_count = overage.overage_amount
archived_ids = []
if quota_type == 'MAX_ADDITIONAL_USERS':
# Archive oldest non-owner users
users_to_archive = User.objects.filter(
tenant=self.tenant,
is_archived_by_quota=False
).exclude(
role=User.Role.TENANT_OWNER
).order_by('date_joined')[:excess_count]
for user in users_to_archive:
user.is_archived_by_quota = True
user.archived_by_quota_at = timezone.now()
user.save()
archived_ids.append(user.id)
elif quota_type == 'MAX_RESOURCES':
from schedule.models import Resource
resources = Resource.objects.filter(
is_archived_by_quota=False
).order_by('created_at')[:excess_count]
for resource in resources:
resource.is_archived_by_quota = True
resource.archived_by_quota_at = timezone.now()
resource.save()
archived_ids.append(resource.id)
elif quota_type == 'MAX_SERVICES':
from schedule.models import Service
services = Service.objects.filter(
is_archived_by_quota=False
).order_by('created_at')[:excess_count]
for service in services:
service.is_archived_by_quota = True
service.archived_by_quota_at = timezone.now()
service.save()
archived_ids.append(service.id)
return archived_ids
# =========================================================================
# Status Methods
# =========================================================================
def get_active_overages(self) -> list[dict]:
"""Get all active quota overages for this tenant."""
overages = QuotaOverage.objects.filter(
tenant=self.tenant,
status='ACTIVE'
)
return [
{
'id': o.id,
'quota_type': o.quota_type,
'display_name': self.QUOTA_CONFIG.get(o.quota_type, {}).get(
'display_name', o.quota_type
),
'current_usage': o.current_usage,
'allowed_limit': o.allowed_limit,
'overage_amount': o.overage_amount,
'days_remaining': o.days_remaining,
'grace_period_ends_at': o.grace_period_ends_at.isoformat() if o.grace_period_ends_at else None,
}
for o in overages
]
def has_active_overages(self) -> bool:
"""Check if tenant has any active quota overages."""
return QuotaOverage.objects.filter(
tenant=self.tenant,
status='ACTIVE'
).exists()
# =============================================================================
# Helper Functions
# =============================================================================
def check_tenant_quotas(tenant: Tenant) -> list[QuotaOverage]:
"""
Check all quotas for a tenant and create overage records if needed.
Call this after plan downgrades or billing failures.
"""
service = QuotaService(tenant)
return service.check_all_quotas()
def process_expired_grace_periods() -> dict:
"""
Process all tenants with expired grace periods.
Call this from a daily Celery task.
Returns:
dict with counts of processed overages and archived resources
"""
results = {
'overages_processed': 0,
'total_archived': 0,
}
# Find all tenants with expired overages
expired_overages = QuotaOverage.objects.filter(
status='ACTIVE',
grace_period_ends_at__lte=timezone.now()
).values_list('tenant_id', flat=True).distinct()
for tenant_id in expired_overages:
try:
tenant = Tenant.objects.get(id=tenant_id)
service = QuotaService(tenant)
archive_results = service.auto_archive_expired()
if archive_results:
logger.info(f"Auto-archived for {tenant.name}: {archive_results}")
results['overages_processed'] += len(archive_results)
results['total_archived'] += sum(archive_results.values())
except Tenant.DoesNotExist:
continue
except Exception as e:
logger.error(f"Error processing expired overages for tenant {tenant_id}: {e}")
return results
def send_grace_period_reminders() -> dict:
"""
Send reminder emails for overages approaching grace period end.
Call this from a daily Celery task.
Returns:
dict with counts of reminders sent
"""
now = timezone.now()
week_from_now = now + timedelta(days=7)
day_from_now = now + timedelta(days=1)
results = {
'week_reminders_sent': 0,
'day_reminders_sent': 0,
}
# 7-day reminders
week_overages = QuotaOverage.objects.filter(
status='ACTIVE',
week_reminder_sent_at__isnull=True,
grace_period_ends_at__lte=week_from_now,
grace_period_ends_at__gt=day_from_now
)
for overage in week_overages:
try:
service = QuotaService(overage.tenant)
service.send_overage_notification(overage, 'week_reminder')
results['week_reminders_sent'] += 1
except Exception as e:
logger.error(f"Error sending week reminder for overage {overage.id}: {e}")
# 1-day reminders
day_overages = QuotaOverage.objects.filter(
status='ACTIVE',
day_reminder_sent_at__isnull=True,
grace_period_ends_at__lte=day_from_now
)
for overage in day_overages:
try:
service = QuotaService(overage.tenant)
service.send_overage_notification(overage, 'day_reminder')
results['day_reminders_sent'] += 1
except Exception as e:
logger.error(f"Error sending day reminder for overage {overage.id}: {e}")
return results

View File

@@ -0,0 +1,273 @@
"""
Celery tasks for quota management and grace period enforcement.
These tasks run periodically to:
1. Check for new quota overages across all tenants
2. Send reminder emails (7 days, 1 day before grace period ends)
3. Auto-archive resources when grace period expires
"""
from celery import shared_task
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
@shared_task
def check_all_tenant_quotas():
"""
Check all tenants for quota overages and create QuotaOverage records.
This task should run daily (or after plan changes) to detect new overages.
Returns:
dict: Summary of overages found and notifications sent
"""
from smoothschedule.tenants.models import Tenant
from .quota_service import check_tenant_quotas
results = {
'tenants_checked': 0,
'new_overages': 0,
'notifications_sent': 0,
'errors': [],
}
# Get all active tenants
tenants = Tenant.objects.filter(is_active=True)
for tenant in tenants:
try:
results['tenants_checked'] += 1
overages = check_tenant_quotas(tenant)
for overage in overages:
results['new_overages'] += 1
if overage.initial_email_sent_at:
results['notifications_sent'] += 1
except Exception as e:
error_msg = f"Error checking tenant {tenant.id}: {str(e)}"
logger.error(error_msg, exc_info=True)
results['errors'].append(error_msg)
logger.info(
f"Quota check complete: {results['tenants_checked']} tenants checked, "
f"{results['new_overages']} new overages, "
f"{results['notifications_sent']} notifications sent"
)
return results
@shared_task
def send_quota_reminder_emails():
"""
Send reminder emails for active quota overages approaching deadline.
Sends:
- 7-day reminder (if not already sent)
- 1-day reminder (if not already sent)
This task should run daily.
Returns:
dict: Summary of reminders sent
"""
from .quota_service import send_grace_period_reminders
results = {
'week_reminders_sent': 0,
'day_reminders_sent': 0,
'errors': [],
}
try:
reminder_results = send_grace_period_reminders()
results['week_reminders_sent'] = reminder_results.get('week_reminders_sent', 0)
results['day_reminders_sent'] = reminder_results.get('day_reminders_sent', 0)
except Exception as e:
error_msg = f"Error sending reminder emails: {str(e)}"
logger.error(error_msg, exc_info=True)
results['errors'].append(error_msg)
logger.info(
f"Quota reminders sent: {results['week_reminders_sent']} week reminders, "
f"{results['day_reminders_sent']} day reminders"
)
return results
@shared_task
def process_expired_quotas():
"""
Process quota overages where grace period has expired.
Auto-archives oldest resources for each expired overage.
This task should run daily, preferably early morning.
Returns:
dict: Summary of auto-archiving actions taken
"""
from .quota_service import process_expired_grace_periods
results = {
'overages_processed': 0,
'resources_archived': 0,
'errors': [],
}
try:
archive_results = process_expired_grace_periods()
results['overages_processed'] = archive_results.get('overages_processed', 0)
results['resources_archived'] = archive_results.get('total_archived', 0)
except Exception as e:
error_msg = f"Error processing expired quotas: {str(e)}"
logger.error(error_msg, exc_info=True)
results['errors'].append(error_msg)
logger.info(
f"Expired quotas processed: {results['overages_processed']} overages, "
f"{results['resources_archived']} resources archived"
)
return results
@shared_task
def check_single_tenant_quotas(tenant_id: int):
"""
Check quotas for a single tenant.
Use this when a tenant changes plans (upgrade/downgrade).
Args:
tenant_id: ID of the tenant to check
Returns:
dict: Overages found for this tenant
"""
from smoothschedule.tenants.models import Tenant
from .quota_service import check_tenant_quotas
try:
tenant = Tenant.objects.get(id=tenant_id)
overages = check_tenant_quotas(tenant)
return {
'tenant_id': tenant_id,
'tenant_name': tenant.name,
'overages_found': len(overages),
'overages': [
{
'quota_type': o.quota_type,
'current_usage': o.current_usage,
'allowed_limit': o.allowed_limit,
'overage_amount': o.overage_amount,
'grace_period_ends_at': o.grace_period_ends_at.isoformat(),
}
for o in overages
]
}
except Tenant.DoesNotExist:
logger.error(f"Tenant {tenant_id} not found")
return {'error': f'Tenant {tenant_id} not found'}
except Exception as e:
logger.error(f"Error checking tenant {tenant_id}: {str(e)}", exc_info=True)
return {'error': str(e)}
@shared_task
def resolve_tenant_overage(overage_id: int):
"""
Check if a specific quota overage has been resolved.
Call this after a tenant archives resources or upgrades plan.
Args:
overage_id: ID of the QuotaOverage to check
Returns:
dict: Resolution status
"""
from .models import QuotaOverage
from .quota_service import QuotaService
try:
overage = QuotaOverage.objects.select_related('tenant').get(id=overage_id)
if overage.status != 'ACTIVE':
return {
'overage_id': overage_id,
'already_resolved': True,
'status': overage.status,
}
# Get current usage
service = QuotaService(overage.tenant)
current_usage = service.get_current_usage(overage.quota_type)
allowed_limit = service.get_limit(overage.quota_type)
if current_usage <= allowed_limit:
# Overage resolved!
overage.resolve()
return {
'overage_id': overage_id,
'resolved': True,
'current_usage': current_usage,
'allowed_limit': allowed_limit,
}
else:
# Still over limit
overage.current_usage = current_usage
overage.overage_amount = current_usage - allowed_limit
overage.save()
return {
'overage_id': overage_id,
'resolved': False,
'current_usage': current_usage,
'allowed_limit': allowed_limit,
'still_over_by': current_usage - allowed_limit,
}
except QuotaOverage.DoesNotExist:
logger.error(f"QuotaOverage {overage_id} not found")
return {'error': f'QuotaOverage {overage_id} not found'}
except Exception as e:
logger.error(f"Error resolving overage {overage_id}: {str(e)}", exc_info=True)
return {'error': str(e)}
@shared_task
def cleanup_old_resolved_overages(days_to_keep: int = 90):
"""
Clean up old resolved/archived quota overage records.
Args:
days_to_keep: Keep records from the last N days (default: 90)
Returns:
int: Number of records deleted
"""
from .models import QuotaOverage
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=days_to_keep)
# Only delete resolved or archived overages
deleted_count, _ = QuotaOverage.objects.filter(
status__in=['RESOLVED', 'ARCHIVED', 'CANCELLED'],
updated_at__lt=cutoff_date
).delete()
logger.info(f"Deleted {deleted_count} old quota overage records")
return deleted_count

View File

@@ -24,7 +24,7 @@ class Command(BaseCommand):
force = options['force'] force = options['force']
plans = [ plans = [
# Free Tier # Free Tier - No payments included (requires Payments add-on)
{ {
'name': 'Free', 'name': 'Free',
'description': 'Perfect for getting started. Try out the core features with no commitment.', 'description': 'Perfect for getting started. Try out the core features with no commitment.',
@@ -50,7 +50,7 @@ class Command(BaseCommand):
'max_email_templates': 3, 'max_email_templates': 3,
}, },
'permissions': { 'permissions': {
'can_accept_payments': False, 'can_accept_payments': False, # Requires Payments add-on
'sms_reminders': False, 'sms_reminders': False,
'advanced_reporting': False, 'advanced_reporting': False,
'priority_support': False, 'priority_support': False,
@@ -63,7 +63,7 @@ class Command(BaseCommand):
'can_customize_booking_page': False, 'can_customize_booking_page': False,
'can_export_data': False, 'can_export_data': False,
}, },
'transaction_fee_percent': 0, 'transaction_fee_percent': 0, # No payments
'transaction_fee_fixed': 0, 'transaction_fee_fixed': 0,
'sms_enabled': False, 'sms_enabled': False,
'masked_calling_enabled': False, 'masked_calling_enabled': False,
@@ -73,7 +73,7 @@ class Command(BaseCommand):
'is_most_popular': False, 'is_most_popular': False,
'show_price': True, 'show_price': True,
}, },
# Starter Tier # Starter Tier - Stripe charges 2.9% + $0.30, we charge 4% + $0.40
{ {
'name': 'Starter', 'name': 'Starter',
'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.', 'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.',
@@ -85,7 +85,7 @@ class Command(BaseCommand):
'Up to 5 team members', 'Up to 5 team members',
'Up to 15 resources', 'Up to 15 resources',
'Unlimited appointments', 'Unlimited appointments',
'Online payments (2.9% + $0.30)', 'Online payments (4% + $0.40)',
'SMS reminders', 'SMS reminders',
'Custom booking page colors', 'Custom booking page colors',
'Google Calendar sync', 'Google Calendar sync',
@@ -114,8 +114,8 @@ class Command(BaseCommand):
'can_customize_booking_page': True, 'can_customize_booking_page': True,
'can_export_data': True, 'can_export_data': True,
}, },
'transaction_fee_percent': 2.9, 'transaction_fee_percent': 4.0, # Stripe: 2.9%, our margin: 1.1%
'transaction_fee_fixed': 0.30, 'transaction_fee_fixed': 40, # 40 cents (Stripe: 30¢, our margin: 10¢)
'sms_enabled': True, 'sms_enabled': True,
'sms_price_per_message_cents': 3, 'sms_price_per_message_cents': 3,
'masked_calling_enabled': False, 'masked_calling_enabled': False,
@@ -125,7 +125,7 @@ class Command(BaseCommand):
'is_most_popular': False, 'is_most_popular': False,
'show_price': True, 'show_price': True,
}, },
# Professional Tier # Professional Tier - Stripe charges 2.9% + $0.30, we charge 3.5% + $0.35
{ {
'name': 'Professional', 'name': 'Professional',
'description': 'For growing teams that need powerful automation and customization.', 'description': 'For growing teams that need powerful automation and customization.',
@@ -137,7 +137,7 @@ class Command(BaseCommand):
'Up to 15 team members', 'Up to 15 team members',
'Unlimited resources', 'Unlimited resources',
'Unlimited appointments', 'Unlimited appointments',
'Lower payment fees (2.5% + $0.25)', 'Lower payment fees (3.5% + $0.35)',
'SMS & masked calling', 'SMS & masked calling',
'Custom domain', 'Custom domain',
'Advanced analytics', 'Advanced analytics',
@@ -168,8 +168,8 @@ class Command(BaseCommand):
'can_customize_booking_page': True, 'can_customize_booking_page': True,
'can_export_data': True, 'can_export_data': True,
}, },
'transaction_fee_percent': 2.5, 'transaction_fee_percent': 3.5, # Stripe: 2.9%, our margin: 0.6%
'transaction_fee_fixed': 0.25, 'transaction_fee_fixed': 35, # 35 cents (Stripe: 30¢, our margin: 5¢)
'sms_enabled': True, 'sms_enabled': True,
'sms_price_per_message_cents': 3, 'sms_price_per_message_cents': 3,
'masked_calling_enabled': True, 'masked_calling_enabled': True,
@@ -184,7 +184,7 @@ class Command(BaseCommand):
'is_most_popular': True, 'is_most_popular': True,
'show_price': True, 'show_price': True,
}, },
# Business Tier # Business Tier - Stripe charges 2.9% + $0.30, we charge 3.25% + $0.32
{ {
'name': 'Business', 'name': 'Business',
'description': 'For established businesses with multiple locations or large teams.', 'description': 'For established businesses with multiple locations or large teams.',
@@ -196,7 +196,7 @@ class Command(BaseCommand):
'Up to 50 team members', 'Up to 50 team members',
'Unlimited resources', 'Unlimited resources',
'Unlimited appointments', 'Unlimited appointments',
'Lowest payment fees (2.2% + $0.20)', 'Lower payment fees (3.25% + $0.32)',
'All communication features', 'All communication features',
'Multiple custom domains', 'Multiple custom domains',
'White-label option', 'White-label option',
@@ -227,8 +227,8 @@ class Command(BaseCommand):
'can_customize_booking_page': True, 'can_customize_booking_page': True,
'can_export_data': True, 'can_export_data': True,
}, },
'transaction_fee_percent': 2.2, 'transaction_fee_percent': 3.25, # Stripe: 2.9%, our margin: 0.35%
'transaction_fee_fixed': 0.20, 'transaction_fee_fixed': 32, # 32 cents (Stripe: 30¢, our margin: 2¢)
'sms_enabled': True, 'sms_enabled': True,
'sms_price_per_message_cents': 2, 'sms_price_per_message_cents': 2,
'masked_calling_enabled': True, 'masked_calling_enabled': True,
@@ -243,7 +243,7 @@ class Command(BaseCommand):
'is_most_popular': False, 'is_most_popular': False,
'show_price': True, 'show_price': True,
}, },
# Enterprise Tier # Enterprise Tier - Stripe charges 2.9% + $0.30, we charge 3% + $0.30 (minimal margin)
{ {
'name': 'Enterprise', 'name': 'Enterprise',
'description': 'Custom solutions for large organizations with complex needs.', 'description': 'Custom solutions for large organizations with complex needs.',
@@ -254,7 +254,7 @@ class Command(BaseCommand):
'features': [ 'features': [
'Unlimited team members', 'Unlimited team members',
'Unlimited everything', 'Unlimited everything',
'Custom payment terms', 'Lowest payment fees (3% + $0.30)',
'Dedicated infrastructure', 'Dedicated infrastructure',
'Custom integrations', 'Custom integrations',
'SSO/SAML support', 'SSO/SAML support',
@@ -287,8 +287,8 @@ class Command(BaseCommand):
'sso_enabled': True, 'sso_enabled': True,
'dedicated_support': True, 'dedicated_support': True,
}, },
'transaction_fee_percent': 2.0, 'transaction_fee_percent': 3.0, # Stripe: 2.9%, our margin: 0.1%
'transaction_fee_fixed': 0.15, 'transaction_fee_fixed': 30, # 30 cents (Stripe: 30¢, break-even)
'sms_enabled': True, 'sms_enabled': True,
'sms_price_per_message_cents': 2, 'sms_price_per_message_cents': 2,
'masked_calling_enabled': True, 'masked_calling_enabled': True,
@@ -325,9 +325,36 @@ class Command(BaseCommand):
'is_public': True, 'is_public': True,
'show_price': True, 'show_price': True,
}, },
# SMS Notifications Add-on - enables SMS reminders for tiers without it
{
'name': 'SMS Notifications',
'description': 'Send SMS appointment reminders and notifications to your customers.',
'plan_type': 'addon',
'business_tier': '', # Available to any tier without SMS
'price_monthly': 10.00,
'price_yearly': 100.00,
'features': [
'SMS appointment reminders',
'Custom SMS templates',
'Two-way SMS messaging',
'Pay-as-you-go credits ($0.03/msg)',
],
'limits': {},
'permissions': {
'sms_reminders': True,
},
'sms_enabled': True,
'sms_price_per_message_cents': 3,
'transaction_fee_percent': 0,
'transaction_fee_fixed': 0,
'is_active': True,
'is_public': True,
'show_price': True,
# Note: Only show to businesses without sms_reminders permission
},
{ {
'name': 'SMS Bundle', 'name': 'SMS Bundle',
'description': 'Bulk SMS credits at a discounted rate.', 'description': 'Bulk SMS credits at a discounted rate. Requires SMS Notifications.',
'plan_type': 'addon', 'plan_type': 'addon',
'business_tier': '', 'business_tier': '',
'price_monthly': 20.00, 'price_monthly': 20.00,
@@ -346,6 +373,37 @@ class Command(BaseCommand):
'is_active': True, 'is_active': True,
'is_public': True, 'is_public': True,
'show_price': True, 'show_price': True,
# Note: Only show to businesses with sms_reminders permission
},
# Masked Calling Add-on - enables anonymous calling between customers and staff
{
'name': 'Masked Calling',
'description': 'Enable anonymous phone calls between your customers and staff.',
'plan_type': 'addon',
'business_tier': '', # Available to any tier without masked calling
'price_monthly': 15.00,
'price_yearly': 150.00,
'features': [
'Anonymous customer-staff calls',
'Privacy protection for both parties',
'Call recording (optional)',
'Pay-as-you-go minutes ($0.05/min)',
'Includes 1 proxy phone number',
],
'limits': {},
'permissions': {
'can_use_masked_phone_numbers': True,
},
'masked_calling_enabled': True,
'masked_calling_price_per_minute_cents': 5,
'proxy_number_enabled': True,
'proxy_number_monthly_fee_cents': 0, # First number included
'transaction_fee_percent': 0,
'transaction_fee_fixed': 0,
'is_active': True,
'is_public': True,
'show_price': True,
# Note: Only show to businesses without can_use_masked_phone_numbers permission
}, },
{ {
'name': 'Additional Proxy Number', 'name': 'Additional Proxy Number',
@@ -368,6 +426,8 @@ class Command(BaseCommand):
'is_active': True, 'is_active': True,
'is_public': True, 'is_public': True,
'show_price': True, 'show_price': True,
# Note: Only show to businesses WITH can_use_masked_phone_numbers permission
# (either from tier or Masked Calling addon)
}, },
{ {
'name': 'White Label', 'name': 'White Label',
@@ -391,6 +451,34 @@ class Command(BaseCommand):
'is_public': True, 'is_public': True,
'show_price': True, 'show_price': True,
}, },
# Online Payments Add-on - for Free tier users who want payment processing
# Covers Stripe's $2/mo connected account fee + transaction fees
{
'name': 'Online Payments',
'description': 'Accept online payments from your customers. For businesses on Free tier.',
'plan_type': 'addon',
'business_tier': '', # Available to any tier without payments
'price_monthly': 5.00,
'price_yearly': 50.00,
'features': [
'Accept credit/debit cards',
'Stripe Connect integration',
'Automatic payouts',
'Payment analytics',
'5% + $0.50 per transaction',
],
'limits': {},
'permissions': {
'can_accept_payments': True,
},
'transaction_fee_percent': 5.0, # Stripe: 2.9%, our margin: 2.1% (covers $2/mo account fee)
'transaction_fee_fixed': 50, # 50 cents (Stripe: 30¢, our margin: 20¢)
'is_active': True,
'is_public': True,
'show_price': True,
# Note: This addon should only be shown to businesses without can_accept_payments
# The frontend/backend should filter visibility based on current permissions
},
] ]
created_count = 0 created_count = 0

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-02 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('platform_admin', '0010_subscriptionplan_default_auto_reload_amount_cents_and_more'),
]
operations = [
migrations.AlterField(
model_name='subscriptionplan',
name='business_tier',
field=models.CharField(blank=True, choices=[('', 'N/A (Add-on)'), ('Free', 'Free'), ('Starter', 'Starter'), ('Professional', 'Professional'), ('Business', 'Business'), ('Enterprise', 'Enterprise')], default='', max_length=50),
),
]

View File

@@ -205,16 +205,19 @@ class SubscriptionPlan(models.Model):
help_text="Yearly price in dollars" help_text="Yearly price in dollars"
) )
# Business tier this plan corresponds to # Business tier this plan corresponds to (empty for addons)
business_tier = models.CharField( business_tier = models.CharField(
max_length=50, max_length=50,
choices=[ choices=[
('FREE', 'Free'), ('', 'N/A (Add-on)'),
('STARTER', 'Starter'), ('Free', 'Free'),
('PROFESSIONAL', 'Professional'), ('Starter', 'Starter'),
('ENTERPRISE', 'Enterprise'), ('Professional', 'Professional'),
('Business', 'Business'),
('Enterprise', 'Enterprise'),
], ],
default='PROFESSIONAL' blank=True,
default=''
) )
# Features included (stored as JSON array of strings) # Features included (stored as JSON array of strings)

View File

@@ -111,6 +111,16 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
'price_monthly', 'price_yearly', 'business_tier', 'price_monthly', 'price_yearly', 'business_tier',
'features', 'limits', 'permissions', 'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed', 'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
'sms_enabled', 'sms_price_per_message_cents',
# Masked Calling Settings
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
# Proxy Number Settings
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
# Default Credit Settings
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
'default_auto_reload_amount_cents',
# Status flags
'is_active', 'is_public', 'is_most_popular', 'show_price', 'is_active', 'is_public', 'is_most_popular', 'show_price',
'created_at', 'updated_at' 'created_at', 'updated_at'
] ]
@@ -129,6 +139,16 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
'price_monthly', 'price_yearly', 'business_tier', 'price_monthly', 'price_yearly', 'business_tier',
'features', 'limits', 'permissions', 'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed', 'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
'sms_enabled', 'sms_price_per_message_cents',
# Masked Calling Settings
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
# Proxy Number Settings
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
# Default Credit Settings
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
'default_auto_reload_amount_cents',
# Status flags
'is_active', 'is_public', 'is_most_popular', 'show_price', 'is_active', 'is_public', 'is_most_popular', 'show_price',
'create_stripe_product' 'create_stripe_product'
] ]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-12-02 16:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schedule', '0023_email_template'),
]
operations = [
migrations.AddField(
model_name='resource',
name='archived_by_quota_at',
field=models.DateTimeField(blank=True, help_text='When this resource was archived due to quota overage', null=True),
),
migrations.AddField(
model_name='resource',
name='is_archived_by_quota',
field=models.BooleanField(default=False, help_text='True if this resource was archived due to quota overage'),
),
migrations.AddField(
model_name='service',
name='archived_by_quota_at',
field=models.DateTimeField(blank=True, help_text='When this service was archived due to quota overage', null=True),
),
migrations.AddField(
model_name='service',
name='is_archived_by_quota',
field=models.BooleanField(default=False, help_text='True if this service was archived due to quota overage'),
),
]

View File

@@ -33,6 +33,18 @@ class Service(models.Model):
help_text="List of photo URLs in display order" help_text="List of photo URLs in display order"
) )
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
# Quota overage archiving
is_archived_by_quota = models.BooleanField(
default=False,
help_text="True if this service was archived due to quota overage"
)
archived_by_quota_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this service was archived due to quota overage"
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -152,11 +164,22 @@ class Resource(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
# Quota overage archiving
is_archived_by_quota = models.BooleanField(
default=False,
help_text="True if this resource was archived due to quota overage"
)
archived_by_quota_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this resource was archived due to quota overage"
)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']
indexes = [models.Index(fields=['is_active', 'name'])] indexes = [models.Index(fields=['is_active', 'name'])]
def __str__(self): def __str__(self):
cap = "Unlimited" if self.max_concurrent_events == 0 else f"{self.max_concurrent_events} concurrent" cap = "Unlimited" if self.max_concurrent_events == 0 else f"{self.max_concurrent_events} concurrent"
return f"{self.name} ({cap})" return f"{self.name} ({cap})"

View File

@@ -120,6 +120,8 @@ def current_user_view(request):
# Get business info if user has a tenant # Get business info if user has a tenant
business_name = None business_name = None
business_subdomain = None business_subdomain = None
quota_overages = []
if user.tenant: if user.tenant:
business_name = user.tenant.name business_name = user.tenant.name
# user.tenant.subdomain does not exist. Fetch from domains relation. # user.tenant.subdomain does not exist. Fetch from domains relation.
@@ -130,6 +132,16 @@ def current_user_view(request):
else: else:
business_subdomain = user.tenant.schema_name business_subdomain = user.tenant.schema_name
# Check for active quota overages (for owners and managers)
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
from core.quota_service import QuotaService
try:
service = QuotaService(user.tenant)
quota_overages = service.get_active_overages()
except Exception:
# Don't fail login if quota check fails
pass
# Map database roles to frontend roles # Map database roles to frontend roles
role_mapping = { role_mapping = {
'superuser': 'superuser', 'superuser': 'superuser',
@@ -160,6 +172,7 @@ def current_user_view(request):
'permissions': user.permissions, 'permissions': user.permissions,
'can_invite_staff': user.can_invite_staff(), 'can_invite_staff': user.can_invite_staff(),
'can_access_tickets': user.can_access_tickets(), 'can_access_tickets': user.can_access_tickets(),
'quota_overages': quota_overages,
} }
return Response(user_data, status=status.HTTP_200_OK) return Response(user_data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.8 on 2025-12-02 16:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0008_add_mfa_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='archived_by_quota_at',
field=models.DateTimeField(blank=True, help_text='When this user was archived due to quota overage', null=True),
),
migrations.AddField(
model_name='user',
name='is_archived_by_quota',
field=models.BooleanField(default=False, help_text='True if this user was archived due to quota overage. Cannot log in while archived.'),
),
]

View File

@@ -67,6 +67,17 @@ class User(AbstractUser):
help_text="True for sandbox/test mode users - isolated from live data" help_text="True for sandbox/test mode users - isolated from live data"
) )
# Quota overage archiving
is_archived_by_quota = models.BooleanField(
default=False,
help_text="True if this user was archived due to quota overage. Cannot log in while archived."
)
archived_by_quota_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this user was archived due to quota overage"
)
# Additional profile fields # Additional profile fields
phone = models.CharField(max_length=20, blank=True) phone = models.CharField(max_length=20, blank=True)
phone_verified = models.BooleanField( phone_verified = models.BooleanField(

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>URGENT: Final Day to Resolve Quota Overage</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">FINAL NOTICE</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Automatic archiving begins tomorrow</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ owner.first_name|default:owner.username }},</p>
<p><strong>This is your final reminder.</strong> Your <strong>{{ tenant.name }}</strong> account is still over the limit for <strong>{{ display_name }}</strong>, and the grace period ends <strong>tomorrow</strong>.</p>
<div style="background: #fee2e2; border: 2px solid #dc2626; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0 0 10px 0; color: #991b1b; font-size: 24px;">
<strong>⚠️ LESS THAN 24 HOURS</strong>
</p>
<p style="margin: 0; color: #991b1b;">
Grace period ends: <strong>{{ grace_period_ends|date:"F j, Y" }}</strong>
</p>
</div>
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Current Usage:</span>
<strong style="color: #92400e;">{{ current_usage }}</strong>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Your Plan Allows:</span>
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
</div>
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
<span style="color: #92400e;">Will Be Archived:</span>
<strong style="color: #dc2626;">{{ overage_amount }} {{ display_name }}</strong>
</div>
</div>
<h2 style="color: #dc2626; font-size: 18px; margin-top: 30px;">What happens tomorrow?</h2>
<p>If no action is taken, the <strong>{{ overage_amount }} oldest {{ display_name }}</strong> will be automatically archived:</p>
<ul style="padding-left: 20px; color: #6b7280;">
<li style="margin-bottom: 8px;">Archived items will become <strong>read-only</strong></li>
<li style="margin-bottom: 8px;">You will not be able to make changes to archived items</li>
<li style="margin-bottom: 8px;">Data may become <strong>permanently unrecoverable</strong> after archiving</li>
</ul>
<div style="background: #ecfdf5; border: 1px solid #10b981; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; color: #065f46;">
<strong>💡 Last chance to export!</strong> Download your data now at <a href="{{ export_url }}" style="color: #059669;">{{ export_url }}</a> before any archiving occurs.
</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ manage_url }}" style="display: inline-block; background: #dc2626; color: white; padding: 14px 35px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Take Action Now</a>
</div>
<div style="text-align: center; margin: 20px 0;">
<a href="{{ upgrade_url }}" style="color: #10b981; font-weight: 600;">Or upgrade your plan to keep all your {{ display_name }} →</a>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
Need help? Contact our support team immediately and we'll do our best to assist you.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,35 @@
⚠️ FINAL NOTICE: Automatic Archiving Begins Tomorrow
Hi {{ owner.first_name|default:owner.username }},
This is your FINAL reminder. Your {{ tenant.name }} account is still over the limit for {{ display_name }}, and the grace period ends TOMORROW.
⚠️ LESS THAN 24 HOURS REMAINING
Grace period ends: {{ grace_period_ends|date:"F j, Y" }}
CURRENT STATUS
--------------
Current Usage: {{ current_usage }}
Your Plan Allows: {{ allowed_limit }}
Will Be Archived: {{ overage_amount }} {{ display_name }}
WHAT HAPPENS TOMORROW?
----------------------
If no action is taken, the {{ overage_amount }} oldest {{ display_name }} will be automatically archived:
• Archived items will become read-only
• You will not be able to make changes to archived items
• Data may become permanently unrecoverable after archiving
💡 LAST CHANCE TO EXPORT!
Download your data now: {{ export_url }}
TAKE ACTION NOW
---------------
Manage your quota: {{ manage_url }}
Upgrade your plan: {{ upgrade_url }}
Need help? Contact our support team immediately and we'll do our best to assist you.
---
SmoothSchedule

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Action Required: Quota Exceeded</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">Action Required</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your account has exceeded its quota</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ owner.first_name|default:owner.username }},</p>
<p>Your <strong>{{ tenant.name }}</strong> account has exceeded its limit for <strong>{{ display_name }}</strong>.</p>
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Current Usage:</span>
<strong style="color: #92400e;">{{ current_usage }}</strong>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Your Plan Allows:</span>
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
</div>
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
<span style="color: #92400e;">Over Limit By:</span>
<strong style="color: #dc2626;">{{ overage_amount }}</strong>
</div>
</div>
<h2 style="color: #1f2937; font-size: 18px; margin-top: 30px;">What happens now?</h2>
<p>You have <strong>{{ days_remaining }} days</strong> (until {{ grace_period_ends|date:"F j, Y" }}) to resolve this by:</p>
<ol style="padding-left: 20px;">
<li style="margin-bottom: 10px;"><strong>Select which {{ display_name }} to keep active</strong> - You can choose which ones to archive (they'll become read-only but data is preserved)</li>
<li style="margin-bottom: 10px;"><strong>Upgrade your plan</strong> - Get more capacity for your growing business</li>
<li style="margin-bottom: 10px;"><strong>Delete excess {{ display_name }}</strong> - Remove ones you no longer need</li>
</ol>
<div style="background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; color: #991b1b;">
<strong>Important:</strong> After the grace period ends, the oldest {{ display_name }} will be automatically archived. You can download your data before then to keep a copy for your records.
</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ manage_url }}" style="display: inline-block; background: #4f46e5; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-right: 10px;">Manage Quota</a>
<a href="{{ upgrade_url }}" style="display: inline-block; background: #10b981; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600;">Upgrade Plan</a>
</div>
<p style="margin-top: 30px;">
Need to keep your data? <a href="{{ export_url }}" style="color: #4f46e5;">Export it now</a> before making changes.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
If you have any questions, please contact our support team. We're here to help!
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,32 @@
Action Required: Your {{ tenant.name }} Account Has Exceeded Its Quota
Hi {{ owner.first_name|default:owner.username }},
Your {{ tenant.name }} account has exceeded its limit for {{ display_name }}.
CURRENT STATUS
--------------
Current Usage: {{ current_usage }}
Your Plan Allows: {{ allowed_limit }}
Over Limit By: {{ overage_amount }}
WHAT HAPPENS NOW?
-----------------
You have {{ days_remaining }} days (until {{ grace_period_ends|date:"F j, Y" }}) to resolve this by:
1. Select which {{ display_name }} to keep active - You can choose which ones to archive (they'll become read-only but data is preserved)
2. Upgrade your plan - Get more capacity for your growing business
3. Delete excess {{ display_name }} - Remove ones you no longer need
IMPORTANT: After the grace period ends, the oldest {{ display_name }} will be automatically archived. You can download your data before then to keep a copy for your records.
Manage your quota: {{ manage_url }}
Upgrade your plan: {{ upgrade_url }}
Export your data: {{ export_url }}
If you have any questions, please contact our support team. We're here to help!
---
SmoothSchedule

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reminder: 7 Days Left to Resolve Quota Overage</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
<h1 style="color: white; margin: 0; font-size: 24px;">7 Days Remaining</h1>
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your quota overage grace period is ending soon</p>
</div>
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<p>Hi {{ owner.first_name|default:owner.username }},</p>
<p>This is a reminder that your <strong>{{ tenant.name }}</strong> account is still over the limit for <strong>{{ display_name }}</strong>.</p>
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Current Usage:</span>
<strong style="color: #92400e;">{{ current_usage }}</strong>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
<span style="color: #92400e;">Your Plan Allows:</span>
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
</div>
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
<span style="color: #92400e;">Over Limit By:</span>
<strong style="color: #dc2626;">{{ overage_amount }}</strong>
</div>
</div>
<div style="background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="margin: 0 0 10px 0; color: #991b1b; font-size: 18px;">
<strong>⏰ {{ days_remaining }} days left</strong>
</p>
<p style="margin: 0; color: #991b1b;">
Grace period ends on <strong>{{ grace_period_ends|date:"F j, Y" }}</strong>
</p>
</div>
<h2 style="color: #1f2937; font-size: 18px; margin-top: 30px;">Take action now</h2>
<p>To avoid automatic archiving of your {{ display_name }}, please:</p>
<ol style="padding-left: 20px;">
<li style="margin-bottom: 10px;"><strong>Choose which {{ display_name }} to keep</strong> - Select which ones to archive</li>
<li style="margin-bottom: 10px;"><strong>Upgrade your plan</strong> - Increase your limits</li>
<li style="margin-bottom: 10px;"><strong>Export your data</strong> - Download a copy for your records</li>
</ol>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ manage_url }}" style="display: inline-block; background: #f59e0b; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-right: 10px;">Manage Now</a>
<a href="{{ upgrade_url }}" style="display: inline-block; background: #10b981; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600;">Upgrade Plan</a>
</div>
<p style="margin-top: 30px; color: #6b7280; font-size: 14px;">
<strong>What happens if I don't take action?</strong><br>
After the grace period, the oldest {{ display_name }} will be automatically archived. Archived items become read-only, and data may become unrecoverable.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px;">
Questions? Contact our support team - we're happy to help you find the best solution.
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
REMINDER: 7 Days Left to Resolve Quota Overage
Hi {{ owner.first_name|default:owner.username }},
This is a reminder that your {{ tenant.name }} account is still over the limit for {{ display_name }}.
CURRENT STATUS
--------------
Current Usage: {{ current_usage }}
Your Plan Allows: {{ allowed_limit }}
Over Limit By: {{ overage_amount }}
⏰ {{ days_remaining }} DAYS LEFT
Grace period ends on {{ grace_period_ends|date:"F j, Y" }}
TAKE ACTION NOW
---------------
To avoid automatic archiving of your {{ display_name }}, please:
1. Choose which {{ display_name }} to keep - Select which ones to archive
2. Upgrade your plan - Increase your limits
3. Export your data - Download a copy for your records
Manage Now: {{ manage_url }}
Upgrade Plan: {{ upgrade_url }}
Export Data: {{ export_url }}
WHAT HAPPENS IF I DON'T TAKE ACTION?
After the grace period, the oldest {{ display_name }} will be automatically archived. Archived items become read-only, and data may become unrecoverable.
Questions? Contact our support team - we're happy to help you find the best solution.
---
SmoothSchedule