diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c9ea1a2..75d4385 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -84,6 +84,7 @@ const AuthenticationSettings = React.lazy(() => import('./pages/settings/Authent const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings')); const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings')); 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 @@ -705,6 +706,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> ) : ( } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 0aeda74..cd45008 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -11,6 +11,17 @@ export interface LoginCredentials { 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 { user_id: number; username: string; @@ -58,6 +69,10 @@ export interface User { business?: number; business_name?: string; business_subdomain?: string; + permissions?: Record; + can_invite_staff?: boolean; + can_access_tickets?: boolean; + quota_overages?: QuotaOverage[]; } /** diff --git a/frontend/src/api/quota.ts b/frontend/src/api/quota.ts new file mode 100644 index 0000000..a320131 --- /dev/null +++ b/frontend/src/api/quota.ts @@ -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; +} + +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 => { + const response = await apiClient.get('/quota/status/'); + return response.data; +}; + +/** + * Get resources for a specific quota type + */ +export const getQuotaResources = async (quotaType: string): Promise => { + const response = await apiClient.get(`/quota/resources/${quotaType}/`); + return response.data; +}; + +/** + * Archive resources to resolve quota overage + */ +export const archiveResources = async ( + quotaType: string, + resourceIds: number[] +): Promise => { + const response = await apiClient.post('/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 => { + const response = await apiClient.get(`/quota/overages/${overageId}/`); + return response.data; +}; diff --git a/frontend/src/components/QuotaWarningBanner.tsx b/frontend/src/components/QuotaWarningBanner.tsx new file mode 100644 index 0000000..3f462de --- /dev/null +++ b/frontend/src/components/QuotaWarningBanner.tsx @@ -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 = ({ 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 ( +
+
+
+
+ +
+ + {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 }) + } + + + {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) + } + )} + +
+
+
+ + {t('quota.banner.manage', 'Manage Quota')} + + + {onDismiss && ( + + )} +
+
+ + {/* Show additional overages if there are more than one */} + {overages.length > 1 && ( +
+ {t('quota.banner.allOverages', 'All overages:')} +
    + {overages.map((overage) => ( +
  • + {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 }) + } +
  • + ))} +
+
+ )} +
+
+ ); +}; + +export default QuotaWarningBanner; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 1b4f12b..99863c5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -543,7 +543,11 @@ "acceptPayments": "Accept Payments", "acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.", "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": { "title": "Profile Settings", @@ -1051,6 +1055,36 @@ "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": { "title": "Upgrade Your Plan", "subtitle": "Choose the perfect plan for {{businessName}}", diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx index 87dd8f5..107a5b7 100644 --- a/frontend/src/layouts/BusinessLayout.tsx +++ b/frontend/src/layouts/BusinessLayout.tsx @@ -4,6 +4,7 @@ import Sidebar from '../components/Sidebar'; import TopBar from '../components/TopBar'; import TrialBanner from '../components/TrialBanner'; import SandboxBanner from '../components/SandboxBanner'; +import QuotaWarningBanner from '../components/QuotaWarningBanner'; import { Business, User } from '../types'; import MasqueradeBanner from '../components/MasqueradeBanner'; import OnboardingWizard from '../components/OnboardingWizard'; @@ -283,6 +284,10 @@ const BusinessLayoutContent: React.FC = ({ business, user, onStop={handleStopMasquerade} /> )} + {/* Quota overage warning banner - show for owners and managers */} + {user.quota_overages && user.quota_overages.length > 0 && ( + + )} {/* Sandbox mode banner */} {/* Show trial banner if trial is active and payments not yet enabled */} diff --git a/frontend/src/layouts/SettingsLayout.tsx b/frontend/src/layouts/SettingsLayout.tsx index c477d51..4593a18 100644 --- a/frontend/src/layouts/SettingsLayout.tsx +++ b/frontend/src/layouts/SettingsLayout.tsx @@ -20,6 +20,7 @@ import { Phone, CreditCard, Webhook, + AlertTriangle, } from 'lucide-react'; import { SettingsSidebarSection, @@ -137,6 +138,12 @@ const SettingsLayout: React.FC = () => { label={t('settings.billing.title', 'Plan & Billing')} description={t('settings.billing.description', 'Subscription, invoices')} /> + diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index dde1c66..4f3d09a 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -785,12 +785,14 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading can_api_access: 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 ? parseFloat(plan.transaction_fee_percent) - : 0, + : 4.0, // Default 4% for new plans transaction_fee_fixed: plan?.transaction_fee_fixed ? parseFloat(plan.transaction_fee_fixed) - : 0, + : 40, // Default 40 cents for new plans // Communication pricing sms_enabled: plan?.sms_enabled ?? false, sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3, @@ -1048,6 +1050,9 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading /> +

+ 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. +

{/* Communication Pricing */} @@ -1258,7 +1263,7 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading
= { + 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 { business: PlatformBusiness | null; isOpen: boolean; @@ -11,34 +60,144 @@ interface BusinessEditModalProps { const BusinessEditModal: React.FC = ({ business, isOpen, onClose }) => { const updateBusinessMutation = useUpdateBusiness(); + const { data: subscriptionPlans } = useSubscriptionPlans(); const [editForm, setEditForm] = useState({ name: '', is_active: true, subscription_tier: 'FREE', + // Limits max_users: 5, 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_accept_payments: false, can_use_custom_domain: false, can_white_label: false, can_api_access: false, - // New feature limits (not yet implemented) - limits: { - can_add_video_conferencing: false, - max_event_types: null as number | null, - max_calendars_connected: null as number | null, - can_connect_to_api: false, - can_book_repeated_events: false, - can_require_2fa: false, - can_download_logs: false, - can_delete_data: false, + // Extended Permissions + permissions: { + // Payments & Revenue + can_process_refunds: false, + can_create_packages: false, + // Communication + sms_reminders: false, can_use_masked_phone_numbers: false, - can_use_pos: false, - can_use_mobile_app: false, + can_use_email_templates: 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 = { + '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 useEffect(() => { if (business) { @@ -46,30 +205,51 @@ const BusinessEditModal: React.FC = ({ business, isOpen, name: business.name, is_active: business.is_active, subscription_tier: business.tier, + // Limits max_users: business.max_users || 5, 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_accept_payments: business.can_accept_payments || false, can_use_custom_domain: business.can_use_custom_domain || false, can_white_label: business.can_white_label || false, can_api_access: business.can_api_access || false, - limits: { - can_add_video_conferencing: false, - max_event_types: null, - max_calendars_connected: null, - can_connect_to_api: false, - can_book_repeated_events: false, - can_require_2fa: false, - can_download_logs: false, - can_delete_data: false, - can_use_masked_phone_numbers: false, - can_use_pos: false, - can_use_mobile_app: false, + // Extended Permissions + permissions: { + can_process_refunds: (business as any).permissions?.can_process_refunds || false, + can_create_packages: (business as any).permissions?.can_create_packages || false, + sms_reminders: (business as any).permissions?.sms_reminders || false, + can_use_masked_phone_numbers: (business as any).permissions?.can_use_masked_phone_numbers || false, + can_use_email_templates: (business as any).permissions?.can_use_email_templates || false, + can_customize_booking_page: (business as any).permissions?.can_customize_booking_page || false, + advanced_reporting: (business as any).permissions?.advanced_reporting || false, + can_create_plugins: (business as any).permissions?.can_create_plugins || false, + can_export_data: (business as any).permissions?.can_export_data || 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]); + // Helper for permission changes + const handlePermissionChange = (key: string, value: boolean) => { + setEditForm(prev => ({ + ...prev, + permissions: { + ...prev.permissions, + [key]: value, + }, + })); + }; + const handleEditSave = () => { if (!business) return; @@ -90,7 +270,7 @@ const BusinessEditModal: React.FC = ({ business, isOpen, return (
-
+
{/* Modal Header */}

@@ -141,12 +321,22 @@ const BusinessEditModal: React.FC = ({ business, isOpen, {/* Subscription Tier */}
- +
+ + +
+

+ Changing tier will auto-update limits and permissions to tier defaults +

- {/* Limits */} -
-
- - setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 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" - /> -
-
- - setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 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" - /> + {/* Limits Configuration */} +
+

+ Limits Configuration +

+

+ Use -1 for unlimited. These limits control what this business can create. +

+
+
+ + setEditForm({ ...editForm, max_users: 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
- {/* Permissions Section */} -
-

+ {/* Features & Permissions */} +
+

- Platform Permissions -

+ Features & Permissions +

+

+ Control which features are available to this business. +

-
- {/* Can Manage OAuth Credentials */} -
-
- -

- Allow this business to configure their own OAuth app credentials -

-
- + {/* Payments & Revenue */} +
+

Payments & Revenue

+
+ + +
+
- {/* Can Accept Payments */} -
-
- -

- Enable Stripe Connect for payment processing -

-
- + {/* Communication */} +
+

Communication

+
+ + +
+
- {/* Can Use Custom Domain */} -
-
- -

- Allow custom domain configuration -

-
- + {/* Customization */} +
+

Customization

+
+ + +
+
- {/* Can White Label */} -
-
- -

- Allow removal of SmoothSchedule branding -

-
- + {/* Advanced Features */} +
+

Advanced Features

+
+ + + + + +
+
- {/* Can API Access */} -
-
- -

- Enable API access for integrations -

-
- + {/* Support & Enterprise */} +
+

Support & Enterprise

+
+ + + +
- {/* Feature Limits (Not Yet Implemented) */} -
-
- - - Coming Soon - -
-
- {/* Video Conferencing */} - - - {/* Event Types Limit */} -
- -
-
- Unlimited event types -
- -
-
- - {/* Calendars Connected Limit */} -
- -
-
- Unlimited calendar connections -
- -
-
- - {/* API Access */} - - - {/* Repeated Events */} - - - {/* 2FA */} - - - {/* Download Logs */} - - - {/* Delete Data */} - - - {/* Masked Phone Numbers */} - - - {/* POS Integration */} - - - {/* Mobile App */} - -
-
{/* Modal Footer */} diff --git a/frontend/src/pages/settings/QuotaSettings.tsx b/frontend/src/pages/settings/QuotaSettings.tsx new file mode 100644 index 0000000..161c378 --- /dev/null +++ b/frontend/src/pages/settings/QuotaSettings.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedOverage, setExpandedOverage] = useState(null); + const [resources, setResources] = useState>({}); + const [selectedResources, setSelectedResources] = useState>>({}); + const [archiving, setArchiving] = useState(false); + const [successMessage, setSuccessMessage] = useState(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 ; + case 'MAX_RESOURCES': + return ; + case 'MAX_SERVICES': + return ; + default: + return ; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + if (!isOwner && !isManager) { + return ( +
+

+ Only business owners and managers can access quota settings. +

+
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + const hasOverages = quotaStatus && quotaStatus.active_overages.length > 0; + + return ( +
+ {/* Header */} +
+

+ + {t('quota.page.title', 'Quota Management')} +

+

+ {t('quota.page.subtitle', 'Manage your account limits and usage')} +

+
+ + {/* Success Message */} + {successMessage && ( +
+ + {successMessage} +
+ )} + + {/* No overages */} + {!hasOverages && ( +
+ +

+ {t('quota.page.noOverages', 'You are within your plan limits.')} +

+

+ All your resources are within the limits of your current plan. +

+
+ )} + + {/* Usage Overview */} + {quotaStatus && ( +
+

+ Current Usage +

+
+ {Object.entries(quotaStatus.usage).map(([quotaType, usage]) => { + const isOver = usage.limit > 0 && usage.current > usage.limit; + return ( +
+
+ {getQuotaIcon(quotaType)} + + {usage.display_name} + +
+
+ + {usage.current} + + + {' / '} + {usage.limit < 0 ? 'Unlimited' : usage.limit} + +
+ {isOver && ( +

+ Over by {usage.current - usage.limit} +

+ )} +
+ ); + })} +
+
+ )} + + {/* Active Overages */} + {hasOverages && ( +
+

+ Active Overages +

+ + {quotaStatus!.active_overages.map((overage) => ( +
+ {/* Overage Header */} + + + {/* Expanded Content */} + {expandedOverage === overage.id && ( +
+
+

+ + {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 + } + )} +

+
+ +
+ {t('quota.page.selectToArchive', 'Select items to archive')} +
+ + {/* Resource List */} + {resources[overage.quota_type] ? ( +
+ {resources[overage.quota_type] + .filter(r => !r.is_archived) + .map((resource) => ( + + ))} +
+ ) : ( +
+ +
+ )} + + {/* Already Archived */} + {resources[overage.quota_type]?.some(r => r.is_archived) && ( +
+
Already Archived
+
+ {resources[overage.quota_type] + .filter(r => r.is_archived) + .map((resource) => ( +
+ + {resource.name} + {resource.archived_at && ( + + Archived {formatDate(resource.archived_at)} + + )} +
+ ))} +
+
+ )} + + {/* Actions */} +
+ + + {t('quota.page.upgradeInstead', 'Upgrade Plan Instead')} + + +
+ + {/* Archive Warning */} +

+ + {t('quota.page.archiveWarning', + 'Archived items will become read-only and cannot be used for new bookings.' + )} +

+
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default QuotaSettings; diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx index fe301ee..c163dd4 100644 --- a/frontend/src/pages/settings/index.tsx +++ b/frontend/src/pages/settings/index.tsx @@ -22,3 +22,6 @@ export { default as CommunicationSettings } from './CommunicationSettings'; // Billing export { default as BillingSettings } from './BillingSettings'; + +// Quota Management +export { default as QuotaSettings } from './QuotaSettings'; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 68095fa..f3800d7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -93,6 +93,17 @@ export interface NotificationPreferences { 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 { id: string | number; username?: string; @@ -109,6 +120,7 @@ export interface User { can_invite_staff?: boolean; can_access_tickets?: boolean; permissions?: Record; + quota_overages?: QuotaOverage[]; } export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT'; diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py index bde5767..5d4d739 100644 --- a/smoothschedule/config/settings/base.py +++ b/smoothschedule/config/settings/base.py @@ -279,6 +279,28 @@ CELERY_TASK_TIME_LIMIT = 5 * 60 CELERY_TASK_SOFT_TIME_LIMIT = 60 # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler 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 CELERY_WORKER_SEND_TASK_EVENTS = True # https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index 4e0a927..3c0c65b 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -36,6 +36,13 @@ from core.email_autoconfig import ( AppleConfigProfileView, WellKnownAutoconfigView, ) +from core.api_views import ( + quota_status_view, + quota_resources_view, + quota_archive_view, + quota_unarchive_view, + quota_overage_detail_view, +) urlpatterns = [ # Django Admin, use {% url 'admin:index' %} @@ -115,6 +122,12 @@ urlpatterns += [ path("sandbox/status/", sandbox_status_view, name="sandbox_status"), path("sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"), path("sandbox/reset/", sandbox_reset_view, name="sandbox_reset"), + # Quota Management API + path("quota/status/", quota_status_view, name="quota_status"), + path("quota/resources//", 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//", quota_overage_detail_view, name="quota_overage_detail"), # MFA (Two-Factor Authentication) API path("auth/mfa/status/", mfa_status, name="mfa_status"), path("auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"), diff --git a/smoothschedule/core/api_views.py b/smoothschedule/core/api_views.py new file mode 100644 index 0000000..96023dd --- /dev/null +++ b/smoothschedule/core/api_views.py @@ -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// + + 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// + """ + 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, + }) diff --git a/smoothschedule/core/management/commands/setup_quota_tasks.py b/smoothschedule/core/management/commands/setup_quota_tasks.py new file mode 100644 index 0000000..bb2115e --- /dev/null +++ b/smoothschedule/core/management/commands/setup_quota_tasks.py @@ -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') diff --git a/smoothschedule/core/migrations/0017_alter_tierlimit_feature_code_quotaoverage.py b/smoothschedule/core/migrations/0017_alter_tierlimit_feature_code_quotaoverage.py new file mode 100644 index 0000000..2847c94 --- /dev/null +++ b/smoothschedule/core/migrations/0017_alter_tierlimit_feature_code_quotaoverage.py @@ -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')], + }, + ), + ] diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py index 7c5d963..66f2bb1 100644 --- a/smoothschedule/core/models.py +++ b/smoothschedule/core/models.py @@ -774,21 +774,176 @@ class TierLimit(models.Model): ('ENTERPRISE', 'Enterprise'), ] ) - + feature_code = models.CharField( 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( default=0, help_text="Maximum allowed count for this feature" ) - + class Meta: unique_together = ['tier', 'feature_code'] ordering = ['tier', 'feature_code'] - + def __str__(self): 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() + diff --git a/smoothschedule/core/permissions.py b/smoothschedule/core/permissions.py index 148215d..6b4c96b 100644 --- a/smoothschedule/core/permissions.py +++ b/smoothschedule/core/permissions.py @@ -198,7 +198,7 @@ def HasQuota(feature_code): permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')] 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: QuotaPermission class configured for the feature @@ -218,9 +218,10 @@ def HasQuota(feature_code): # Map feature codes to model paths for usage counting # CRITICAL: This map must be populated for the permission to work + # Note: MAX_ADDITIONAL_USERS requires special handling (shared schema + exclude owner) USAGE_MAP = { '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_SERVICES': 'schedule.Service', 'MAX_APPOINTMENTS': 'schedule.Event', @@ -271,10 +272,22 @@ def HasQuota(feature_code): return True # 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 - if feature_code == 'MAX_APPOINTMENTS': + elif feature_code == 'MAX_APPOINTMENTS': from django.utils import timezone from datetime import timedelta # Count appointments in current month @@ -286,8 +299,14 @@ def HasQuota(feature_code): start_time__gte=start_of_month, start_time__lt=start_of_next_month ).count() + + # Standard counting for tenant-scoped models 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 if current_count >= limit: diff --git a/smoothschedule/core/quota_service.py b/smoothschedule/core/quota_service.py new file mode 100644 index 0000000..ff66396 --- /dev/null +++ b/smoothschedule/core/quota_service.py @@ -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 diff --git a/smoothschedule/core/tasks.py b/smoothschedule/core/tasks.py new file mode 100644 index 0000000..69ee384 --- /dev/null +++ b/smoothschedule/core/tasks.py @@ -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 diff --git a/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py b/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py index 3cc50db..dbde021 100644 --- a/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py +++ b/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py @@ -24,7 +24,7 @@ class Command(BaseCommand): force = options['force'] plans = [ - # Free Tier + # Free Tier - No payments included (requires Payments add-on) { 'name': 'Free', 'description': 'Perfect for getting started. Try out the core features with no commitment.', @@ -50,7 +50,7 @@ class Command(BaseCommand): 'max_email_templates': 3, }, 'permissions': { - 'can_accept_payments': False, + 'can_accept_payments': False, # Requires Payments add-on 'sms_reminders': False, 'advanced_reporting': False, 'priority_support': False, @@ -63,7 +63,7 @@ class Command(BaseCommand): 'can_customize_booking_page': False, 'can_export_data': False, }, - 'transaction_fee_percent': 0, + 'transaction_fee_percent': 0, # No payments 'transaction_fee_fixed': 0, 'sms_enabled': False, 'masked_calling_enabled': False, @@ -73,7 +73,7 @@ class Command(BaseCommand): 'is_most_popular': False, 'show_price': True, }, - # Starter Tier + # Starter Tier - Stripe charges 2.9% + $0.30, we charge 4% + $0.40 { 'name': 'Starter', '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 15 resources', 'Unlimited appointments', - 'Online payments (2.9% + $0.30)', + 'Online payments (4% + $0.40)', 'SMS reminders', 'Custom booking page colors', 'Google Calendar sync', @@ -114,8 +114,8 @@ class Command(BaseCommand): 'can_customize_booking_page': True, 'can_export_data': True, }, - 'transaction_fee_percent': 2.9, - 'transaction_fee_fixed': 0.30, + 'transaction_fee_percent': 4.0, # Stripe: 2.9%, our margin: 1.1% + 'transaction_fee_fixed': 40, # 40 cents (Stripe: 30¢, our margin: 10¢) 'sms_enabled': True, 'sms_price_per_message_cents': 3, 'masked_calling_enabled': False, @@ -125,7 +125,7 @@ class Command(BaseCommand): 'is_most_popular': False, 'show_price': True, }, - # Professional Tier + # Professional Tier - Stripe charges 2.9% + $0.30, we charge 3.5% + $0.35 { 'name': 'Professional', 'description': 'For growing teams that need powerful automation and customization.', @@ -137,7 +137,7 @@ class Command(BaseCommand): 'Up to 15 team members', 'Unlimited resources', 'Unlimited appointments', - 'Lower payment fees (2.5% + $0.25)', + 'Lower payment fees (3.5% + $0.35)', 'SMS & masked calling', 'Custom domain', 'Advanced analytics', @@ -168,8 +168,8 @@ class Command(BaseCommand): 'can_customize_booking_page': True, 'can_export_data': True, }, - 'transaction_fee_percent': 2.5, - 'transaction_fee_fixed': 0.25, + 'transaction_fee_percent': 3.5, # Stripe: 2.9%, our margin: 0.6% + 'transaction_fee_fixed': 35, # 35 cents (Stripe: 30¢, our margin: 5¢) 'sms_enabled': True, 'sms_price_per_message_cents': 3, 'masked_calling_enabled': True, @@ -184,7 +184,7 @@ class Command(BaseCommand): 'is_most_popular': True, 'show_price': True, }, - # Business Tier + # Business Tier - Stripe charges 2.9% + $0.30, we charge 3.25% + $0.32 { 'name': 'Business', 'description': 'For established businesses with multiple locations or large teams.', @@ -196,7 +196,7 @@ class Command(BaseCommand): 'Up to 50 team members', 'Unlimited resources', 'Unlimited appointments', - 'Lowest payment fees (2.2% + $0.20)', + 'Lower payment fees (3.25% + $0.32)', 'All communication features', 'Multiple custom domains', 'White-label option', @@ -227,8 +227,8 @@ class Command(BaseCommand): 'can_customize_booking_page': True, 'can_export_data': True, }, - 'transaction_fee_percent': 2.2, - 'transaction_fee_fixed': 0.20, + 'transaction_fee_percent': 3.25, # Stripe: 2.9%, our margin: 0.35% + 'transaction_fee_fixed': 32, # 32 cents (Stripe: 30¢, our margin: 2¢) 'sms_enabled': True, 'sms_price_per_message_cents': 2, 'masked_calling_enabled': True, @@ -243,7 +243,7 @@ class Command(BaseCommand): 'is_most_popular': False, 'show_price': True, }, - # Enterprise Tier + # Enterprise Tier - Stripe charges 2.9% + $0.30, we charge 3% + $0.30 (minimal margin) { 'name': 'Enterprise', 'description': 'Custom solutions for large organizations with complex needs.', @@ -254,7 +254,7 @@ class Command(BaseCommand): 'features': [ 'Unlimited team members', 'Unlimited everything', - 'Custom payment terms', + 'Lowest payment fees (3% + $0.30)', 'Dedicated infrastructure', 'Custom integrations', 'SSO/SAML support', @@ -287,8 +287,8 @@ class Command(BaseCommand): 'sso_enabled': True, 'dedicated_support': True, }, - 'transaction_fee_percent': 2.0, - 'transaction_fee_fixed': 0.15, + 'transaction_fee_percent': 3.0, # Stripe: 2.9%, our margin: 0.1% + 'transaction_fee_fixed': 30, # 30 cents (Stripe: 30¢, break-even) 'sms_enabled': True, 'sms_price_per_message_cents': 2, 'masked_calling_enabled': True, @@ -325,9 +325,36 @@ class Command(BaseCommand): 'is_public': 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', - 'description': 'Bulk SMS credits at a discounted rate.', + 'description': 'Bulk SMS credits at a discounted rate. Requires SMS Notifications.', 'plan_type': 'addon', 'business_tier': '', 'price_monthly': 20.00, @@ -346,6 +373,37 @@ class Command(BaseCommand): 'is_active': True, 'is_public': 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', @@ -368,6 +426,8 @@ class Command(BaseCommand): 'is_active': True, 'is_public': 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', @@ -391,6 +451,34 @@ class Command(BaseCommand): 'is_public': 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 diff --git a/smoothschedule/platform_admin/migrations/0011_update_subscription_plan_business_tier.py b/smoothschedule/platform_admin/migrations/0011_update_subscription_plan_business_tier.py new file mode 100644 index 0000000..28a2e14 --- /dev/null +++ b/smoothschedule/platform_admin/migrations/0011_update_subscription_plan_business_tier.py @@ -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), + ), + ] diff --git a/smoothschedule/platform_admin/models.py b/smoothschedule/platform_admin/models.py index 17e3d6a..f87d93e 100644 --- a/smoothschedule/platform_admin/models.py +++ b/smoothschedule/platform_admin/models.py @@ -205,16 +205,19 @@ class SubscriptionPlan(models.Model): 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( max_length=50, choices=[ - ('FREE', 'Free'), - ('STARTER', 'Starter'), - ('PROFESSIONAL', 'Professional'), - ('ENTERPRISE', 'Enterprise'), + ('', 'N/A (Add-on)'), + ('Free', 'Free'), + ('Starter', 'Starter'), + ('Professional', 'Professional'), + ('Business', 'Business'), + ('Enterprise', 'Enterprise'), ], - default='PROFESSIONAL' + blank=True, + default='' ) # Features included (stored as JSON array of strings) diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py index 14e3fc8..7bce429 100644 --- a/smoothschedule/platform_admin/serializers.py +++ b/smoothschedule/platform_admin/serializers.py @@ -111,6 +111,16 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer): 'price_monthly', 'price_yearly', 'business_tier', 'features', 'limits', 'permissions', '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', 'created_at', 'updated_at' ] @@ -129,6 +139,16 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer): 'price_monthly', 'price_yearly', 'business_tier', 'features', 'limits', 'permissions', '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', 'create_stripe_product' ] diff --git a/smoothschedule/schedule/migrations/0024_resource_archived_by_quota_at_and_more.py b/smoothschedule/schedule/migrations/0024_resource_archived_by_quota_at_and_more.py new file mode 100644 index 0000000..bf777e5 --- /dev/null +++ b/smoothschedule/schedule/migrations/0024_resource_archived_by_quota_at_and_more.py @@ -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'), + ), + ] diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py index 5e550f7..8f4f7fb 100644 --- a/smoothschedule/schedule/models.py +++ b/smoothschedule/schedule/models.py @@ -33,6 +33,18 @@ class Service(models.Model): help_text="List of photo URLs in display order" ) 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) updated_at = models.DateTimeField(auto_now=True) @@ -152,11 +164,22 @@ class Resource(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=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: ordering = ['name'] indexes = [models.Index(fields=['is_active', 'name'])] - + def __str__(self): cap = "Unlimited" if self.max_concurrent_events == 0 else f"{self.max_concurrent_events} concurrent" return f"{self.name} ({cap})" diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index cd7cdd3..e6f95ac 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -120,6 +120,8 @@ def current_user_view(request): # Get business info if user has a tenant business_name = None business_subdomain = None + quota_overages = [] + if user.tenant: business_name = user.tenant.name # user.tenant.subdomain does not exist. Fetch from domains relation. @@ -130,6 +132,16 @@ def current_user_view(request): else: 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 role_mapping = { 'superuser': 'superuser', @@ -160,6 +172,7 @@ def current_user_view(request): 'permissions': user.permissions, 'can_invite_staff': user.can_invite_staff(), 'can_access_tickets': user.can_access_tickets(), + 'quota_overages': quota_overages, } return Response(user_data, status=status.HTTP_200_OK) diff --git a/smoothschedule/smoothschedule/users/migrations/0009_user_archived_by_quota_at_user_is_archived_by_quota.py b/smoothschedule/smoothschedule/users/migrations/0009_user_archived_by_quota_at_user_is_archived_by_quota.py new file mode 100644 index 0000000..c894854 --- /dev/null +++ b/smoothschedule/smoothschedule/users/migrations/0009_user_archived_by_quota_at_user_is_archived_by_quota.py @@ -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.'), + ), + ] diff --git a/smoothschedule/smoothschedule/users/models.py b/smoothschedule/smoothschedule/users/models.py index 1de0d1e..8e07e63 100644 --- a/smoothschedule/smoothschedule/users/models.py +++ b/smoothschedule/smoothschedule/users/models.py @@ -67,6 +67,17 @@ class User(AbstractUser): 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 phone = models.CharField(max_length=20, blank=True) phone_verified = models.BooleanField( diff --git a/smoothschedule/templates/emails/quota_overage_day_reminder.html b/smoothschedule/templates/emails/quota_overage_day_reminder.html new file mode 100644 index 0000000..16f5a9d --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_day_reminder.html @@ -0,0 +1,74 @@ + + + + + + URGENT: Final Day to Resolve Quota Overage + + +
+

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 +

+

+ Grace period ends: {{ grace_period_ends|date:"F j, Y" }} +

+
+ +
+
+ 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 at {{ export_url }} before any archiving occurs. +

+
+ + + + + +
+ +

+ Need help? Contact our support team immediately and we'll do our best to assist you. +

+
+ + diff --git a/smoothschedule/templates/emails/quota_overage_day_reminder.txt b/smoothschedule/templates/emails/quota_overage_day_reminder.txt new file mode 100644 index 0000000..db9b626 --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_day_reminder.txt @@ -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 diff --git a/smoothschedule/templates/emails/quota_overage_initial.html b/smoothschedule/templates/emails/quota_overage_initial.html new file mode 100644 index 0000000..c616368 --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_initial.html @@ -0,0 +1,66 @@ + + + + + + Action Required: Quota Exceeded + + +
+

Action Required

+

Your account has exceeded its quota

+
+ +
+

Hi {{ owner.first_name|default:owner.username }},

+ +

Your {{ tenant.name }} account has exceeded its limit for {{ display_name }}.

+ +
+
+ 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. +
  3. Upgrade your plan - Get more capacity for your growing business
  4. +
  5. Delete excess {{ display_name }} - Remove ones you no longer need
  6. +
+ +
+

+ 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. +

+
+ + + +

+ Need to keep your data? Export it now before making changes. +

+ +
+ +

+ If you have any questions, please contact our support team. We're here to help! +

+
+ + diff --git a/smoothschedule/templates/emails/quota_overage_initial.txt b/smoothschedule/templates/emails/quota_overage_initial.txt new file mode 100644 index 0000000..b56c12b --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_initial.txt @@ -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 diff --git a/smoothschedule/templates/emails/quota_overage_week_reminder.html b/smoothschedule/templates/emails/quota_overage_week_reminder.html new file mode 100644 index 0000000..8ed6549 --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_week_reminder.html @@ -0,0 +1,70 @@ + + + + + + Reminder: 7 Days Left to Resolve Quota Overage + + +
+

7 Days Remaining

+

Your quota overage grace period is ending soon

+
+ +
+

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 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. +
  3. Upgrade your plan - Increase your limits
  4. +
  5. Export your data - Download a copy for your records
  6. +
+ + + +

+ 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. +

+
+ + diff --git a/smoothschedule/templates/emails/quota_overage_week_reminder.txt b/smoothschedule/templates/emails/quota_overage_week_reminder.txt new file mode 100644 index 0000000..79c7d6b --- /dev/null +++ b/smoothschedule/templates/emails/quota_overage_week_reminder.txt @@ -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