-
-
- Use Custom Domain
-
-
- Allow custom domain configuration
-
-
-
setEditForm({ ...editForm, can_use_custom_domain: !editForm.can_use_custom_domain })}
- className={`${editForm.can_use_custom_domain ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
- role="switch"
- >
-
-
+ {/* Customization */}
+
- {/* Can White Label */}
-
-
-
- Remove Branding
-
-
- Allow removal of SmoothSchedule branding
-
-
-
setEditForm({ ...editForm, can_white_label: !editForm.can_white_label })}
- className={`${editForm.can_white_label ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
- role="switch"
- >
-
-
+ {/* Advanced Features */}
+
- {/* Can API Access */}
-
-
-
- API Access
-
-
- Enable API access for integrations
-
-
-
setEditForm({ ...editForm, can_api_access: !editForm.can_api_access })}
- className={`${editForm.can_api_access ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
- role="switch"
- >
-
-
+ {/* Support & Enterprise */}
+
- {/* Feature Limits (Not Yet Implemented) */}
-
-
-
- Feature Limits & Capabilities
-
-
- Coming Soon
-
-
-
-
{/* 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}
+
+ {t('common.reload', 'Reload')}
+
+
+ );
+ }
+
+ 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 */}
+
toggleOverage(overage)}
+ className="w-full p-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
+ >
+
+
+ {getQuotaIcon(overage.quota_type)}
+
+
+
+ {overage.display_name}
+
+
+ {overage.current_usage} / {overage.allowed_limit}
+ {' • '}
+
+ {overage.overage_amount} over limit
+
+
+
+
+
+
+
+ {overage.days_remaining} days left
+
+ {expandedOverage === overage.id ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 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) => (
+
+ toggleResourceSelection(overage.quota_type, resource.id)}
+ className="h-4 w-4 text-brand-600 rounded border-gray-300 focus:ring-brand-500"
+ />
+
+
+ {resource.name}
+
+ {resource.email && (
+
{resource.email}
+ )}
+ {resource.role && (
+
{resource.role}
+ )}
+ {resource.type && (
+
{resource.type}
+ )}
+
+ {resource.created_at && (
+
+ Created {formatDate(resource.created_at)}
+
+ )}
+
+ ))}
+
+ ) : (
+
+
+
+ )}
+
+ {/* 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 */}
+
+
handleArchive(overage.quota_type)}
+ disabled={
+ archiving ||
+ !selectedResources[overage.quota_type] ||
+ selectedResources[overage.quota_type].size === 0
+ }
+ className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {archiving ? (
+
+ ) : (
+
+ )}
+ {t('quota.page.archiveSelected', 'Archive Selected')}
+ {selectedResources[overage.quota_type]?.size > 0 && (
+
+ {selectedResources[overage.quota_type].size}
+
+ )}
+
+
+ {t('quota.page.upgradeInstead', 'Upgrade Plan Instead')}
+
+
+
+ {t('quota.page.exportData', 'Export Data')}
+
+
+
+ {/* 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:
+
+
+ Select which {{ display_name }} to keep active - You can choose which ones to archive (they'll become read-only but data is preserved)
+ Upgrade your plan - Get more capacity for your growing business
+ 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.
+
+
+
+
+
+
+ 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:
+
+
+ Choose which {{ display_name }} to keep - Select which ones to archive
+ Upgrade your plan - Increase your limits
+ Export your data - Download a copy for your records
+
+
+
+
+
+ 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