{feature.code}
@@ -219,8 +225,13 @@ export const FeaturePicker: React.FC = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
-
+
{feature.name}
+ {isWipFeature(feature.code) && (
+
+ WIP
+
+ )}
{feature.code}
diff --git a/frontend/src/billing/featureCatalog.ts b/frontend/src/billing/featureCatalog.ts
index db98db96..2133c3c2 100644
--- a/frontend/src/billing/featureCatalog.ts
+++ b/frontend/src/billing/featureCatalog.ts
@@ -34,6 +34,8 @@ export interface FeatureCatalogEntry {
description: string;
type: FeatureType;
category: FeatureCategory;
+ /** Feature is work-in-progress and not yet enforced */
+ wip?: boolean;
}
export type FeatureCategory =
@@ -66,13 +68,6 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'communication',
},
- {
- code: 'proxy_number_enabled',
- name: 'Proxy Phone Numbers',
- description: 'Use proxy phone numbers for customer communication',
- type: 'boolean',
- category: 'communication',
- },
// Payments & Commerce
{
@@ -88,6 +83,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Use Point of Sale (POS) system',
type: 'boolean',
category: 'access',
+ wip: true,
},
// Scheduling & Booking
@@ -97,27 +93,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Schedule recurring appointments',
type: 'boolean',
category: 'scheduling',
- },
- {
- code: 'group_bookings',
- name: 'Group Bookings',
- description: 'Allow multiple customers per appointment',
- type: 'boolean',
- category: 'scheduling',
- },
- {
- code: 'waitlist',
- name: 'Waitlist',
- description: 'Enable waitlist for fully booked slots',
- type: 'boolean',
- category: 'scheduling',
- },
- {
- code: 'can_add_video_conferencing',
- name: 'Video Conferencing',
- description: 'Add video conferencing to events',
- type: 'boolean',
- category: 'scheduling',
+ wip: true,
},
// Access & Features
@@ -127,13 +103,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Access the public API for integrations',
type: 'boolean',
category: 'access',
- },
- {
- code: 'can_use_analytics',
- name: 'Analytics Dashboard',
- description: 'Access business analytics and reporting',
- type: 'boolean',
- category: 'access',
+ wip: true,
},
{
code: 'can_use_tasks',
@@ -149,19 +119,13 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'access',
},
- {
- code: 'customer_portal',
- name: 'Customer Portal',
- description: 'Branded self-service portal for customers',
- type: 'boolean',
- category: 'access',
- },
{
code: 'custom_fields',
name: 'Custom Fields',
- description: 'Create custom data fields for resources and events',
+ description: 'Add custom intake fields to services for customer booking',
type: 'boolean',
category: 'access',
+ wip: true,
},
{
code: 'can_export_data',
@@ -169,44 +133,26 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Export data (appointments, customers, etc.)',
type: 'boolean',
category: 'access',
+ wip: true,
},
{
- code: 'can_use_mobile_app',
+ code: 'mobile_app_access',
name: 'Mobile App',
description: 'Access the mobile app for field employees',
type: 'boolean',
category: 'access',
+ wip: true,
+ },
+ {
+ code: 'proxy_number_enabled',
+ name: 'Proxy Phone Numbers',
+ description: 'Assign dedicated phone numbers to staff for customer communication',
+ type: 'boolean',
+ category: 'communication',
+ wip: true,
},
// Integrations
- {
- code: 'calendar_sync',
- name: 'Calendar Sync',
- description: 'Sync with Google Calendar, Outlook, etc.',
- type: 'boolean',
- category: 'integrations',
- },
- {
- code: 'webhooks_enabled',
- name: 'Webhooks',
- description: 'Send webhook notifications for events',
- type: 'boolean',
- category: 'integrations',
- },
- {
- code: 'can_use_plugins',
- name: 'Plugin Integrations',
- description: 'Use third-party plugin integrations',
- type: 'boolean',
- category: 'integrations',
- },
- {
- code: 'can_create_plugins',
- name: 'Create Plugins',
- description: 'Create custom plugins for automation',
- type: 'boolean',
- category: 'integrations',
- },
{
code: 'can_manage_oauth_credentials',
name: 'Manage OAuth',
@@ -217,21 +163,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
// Branding
{
- code: 'custom_branding',
- name: 'Custom Branding',
- description: 'Customize branding colors, logo, and styling',
- type: 'boolean',
- category: 'branding',
- },
- {
- code: 'remove_branding',
- name: 'Remove Branding',
- description: 'Remove SmoothSchedule branding from customer-facing pages',
- type: 'boolean',
- category: 'branding',
- },
- {
- code: 'can_use_custom_domain',
+ code: 'custom_domain',
name: 'Custom Domain',
description: 'Configure a custom domain for your booking page',
type: 'boolean',
@@ -245,6 +177,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Get priority customer support response',
type: 'boolean',
category: 'support',
+ wip: true,
},
// Security & Compliance
@@ -254,6 +187,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Require two-factor authentication for users',
type: 'boolean',
category: 'security',
+ wip: true,
},
{
code: 'sso_enabled',
@@ -261,20 +195,15 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Enable SSO authentication for team members',
type: 'boolean',
category: 'security',
+ wip: true,
},
{
- code: 'can_delete_data',
- name: 'Delete Data',
- description: 'Permanently delete data',
- type: 'boolean',
- category: 'security',
- },
- {
- code: 'can_download_logs',
- name: 'Download Logs',
- description: 'Download system logs',
+ code: 'audit_logs',
+ name: 'Audit Logs',
+ description: 'Track changes and download audit logs',
type: 'boolean',
category: 'security',
+ wip: true,
},
];
@@ -406,6 +335,14 @@ export const isCanonicalFeature = (code: string): boolean => {
return featureMap.has(code);
};
+/**
+ * Check if a feature is work-in-progress (not yet enforced)
+ */
+export const isWipFeature = (code: string): boolean => {
+ const feature = featureMap.get(code);
+ return feature?.wip ?? false;
+};
+
/**
* Get all features by type
*/
diff --git a/frontend/src/components/AppointmentQuotaBanner.tsx b/frontend/src/components/AppointmentQuotaBanner.tsx
new file mode 100644
index 00000000..29b2d3f5
--- /dev/null
+++ b/frontend/src/components/AppointmentQuotaBanner.tsx
@@ -0,0 +1,150 @@
+/**
+ * AppointmentQuotaBanner Component
+ *
+ * Shows a warning banner when the user has reached 90% of their monthly
+ * appointment quota. Dismissable per-user per-billing-period.
+ *
+ * This is different from QuotaWarningBanner which handles grace period
+ * overages for permanent limits like max_users.
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { AlertTriangle, X, TrendingUp, Calendar } from 'lucide-react';
+import { useQuotaStatus, useDismissQuotaBanner } from '../hooks/useQuotaStatus';
+
+interface AppointmentQuotaBannerProps {
+ /** Only show for owners/managers who can take action */
+ userRole?: string;
+}
+
+const AppointmentQuotaBanner: React.FC = ({ userRole }) => {
+ const { t } = useTranslation();
+ const { data: quotaStatus, isLoading } = useQuotaStatus();
+ const dismissMutation = useDismissQuotaBanner();
+
+ // Don't show while loading or if no data
+ if (isLoading || !quotaStatus) {
+ return null;
+ }
+
+ // Don't show if banner shouldn't be shown
+ if (!quotaStatus.warning.show_banner) {
+ return null;
+ }
+
+ // Only show for owners and managers who can take action
+ if (userRole && !['owner', 'manager'].includes(userRole)) {
+ return null;
+ }
+
+ const { appointments, billing_period } = quotaStatus;
+
+ // Don't show if unlimited
+ if (appointments.is_unlimited) {
+ return null;
+ }
+
+ const isOverQuota = appointments.is_over_quota;
+ const percentage = Math.round(appointments.usage_percentage);
+
+ const handleDismiss = () => {
+ dismissMutation.mutate();
+ };
+
+ // Format billing period for display
+ const billingPeriodDisplay = new Date(
+ billing_period.year,
+ billing_period.month - 1
+ ).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
+
+ return (
+
+
+
+ {/* Left: Warning Info */}
+
+
+ {isOverQuota ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isOverQuota
+ ? t('quota.appointmentBanner.overTitle', 'Appointment Quota Exceeded')
+ : t('quota.appointmentBanner.warningTitle', 'Approaching Appointment Limit')}
+
+
+
+ {t('quota.appointmentBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
+ used: appointments.count,
+ limit: appointments.limit,
+ percentage,
+ })}
+ {' • '}
+ {billingPeriodDisplay}
+
+
+
+
+ {/* Right: Actions */}
+
+ {isOverQuota && appointments.overage_count > 0 && (
+
+ {t('quota.appointmentBanner.overage', '+{{count}} @ $0.10 each', {
+ count: appointments.overage_count,
+ })}
+
+ )}
+
+ {t('quota.appointmentBanner.upgrade', 'Upgrade Plan')}
+
+
+
+
+
+ {/* Additional info for over-quota */}
+ {isOverQuota && (
+
+ {t(
+ 'quota.appointmentBanner.overageInfo',
+ 'Appointments over your limit will be billed at $0.10 each at the end of your billing cycle.'
+ )}
+
+ )}
+
+
+ );
+};
+
+export default AppointmentQuotaBanner;
diff --git a/frontend/src/components/PlatformSidebar.tsx b/frontend/src/components/PlatformSidebar.tsx
index 92eb1be2..ca9cb42f 100644
--- a/frontend/src/components/PlatformSidebar.tsx
+++ b/frontend/src/components/PlatformSidebar.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
-import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react';
+import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox, FileText } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -81,6 +81,10 @@ const PlatformSidebar: React.FC = ({ user, isCollapsed, to
{!isCollapsed && {t('nav.staff')}}
+
+
+ {!isCollapsed && {t('nav.emailTemplates', 'Email Templates')}}
+
{!isCollapsed && Billing}
diff --git a/frontend/src/components/StorageQuotaBanner.tsx b/frontend/src/components/StorageQuotaBanner.tsx
new file mode 100644
index 00000000..421597a7
--- /dev/null
+++ b/frontend/src/components/StorageQuotaBanner.tsx
@@ -0,0 +1,179 @@
+/**
+ * StorageQuotaBanner Component
+ *
+ * Shows a warning banner when the user has reached 90% of their database
+ * storage quota. This helps business owners be aware of storage usage
+ * and potential overage charges.
+ *
+ * Storage is measured periodically by a backend task and cached.
+ */
+
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { AlertTriangle, X, Database, HardDrive } from 'lucide-react';
+import { useQuotaStatus } from '../hooks/useQuotaStatus';
+
+interface StorageQuotaBannerProps {
+ /** Only show for owners/managers who can take action */
+ userRole?: string;
+ /** Callback when banner is dismissed */
+ onDismiss?: () => void;
+}
+
+const StorageQuotaBanner: React.FC = ({ userRole, onDismiss }) => {
+ const { t } = useTranslation();
+ const { data: quotaStatus, isLoading } = useQuotaStatus();
+
+ // Don't show while loading or if no data
+ if (isLoading || !quotaStatus) {
+ return null;
+ }
+
+ const { storage, billing_period } = quotaStatus;
+
+ // Don't show if unlimited
+ if (storage.is_unlimited) {
+ return null;
+ }
+
+ // Don't show if not at warning threshold
+ if (!storage.is_at_warning_threshold) {
+ return null;
+ }
+
+ // Only show for owners and managers who can take action
+ if (userRole && !['owner', 'manager'].includes(userRole)) {
+ return null;
+ }
+
+ const isOverQuota = storage.is_over_quota;
+ const percentage = Math.round(storage.usage_percentage);
+
+ // Format storage sizes for display
+ const formatSize = (mb: number): string => {
+ if (mb >= 1024) {
+ return `${(mb / 1024).toFixed(1)} GB`;
+ }
+ return `${mb.toFixed(1)} MB`;
+ };
+
+ const currentDisplay = formatSize(storage.current_size_mb);
+ const limitDisplay = formatSize(storage.quota_limit_mb);
+ const overageDisplay = storage.overage_mb > 0 ? formatSize(storage.overage_mb) : null;
+
+ // Format billing period for display
+ const billingPeriodDisplay = new Date(
+ billing_period.year,
+ billing_period.month - 1
+ ).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
+
+ // Format last measured time
+ const lastMeasuredDisplay = storage.last_measured_at
+ ? new Date(storage.last_measured_at).toLocaleString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ })
+ : null;
+
+ return (
+
+
+
+ {/* Left: Warning Info */}
+
+
+ {isOverQuota ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isOverQuota
+ ? t('quota.storageBanner.overTitle', 'Storage Quota Exceeded')
+ : t('quota.storageBanner.warningTitle', 'Approaching Storage Limit')}
+
+
+
+ {t('quota.storageBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
+ used: currentDisplay,
+ limit: limitDisplay,
+ percentage,
+ })}
+ {' • '}
+ {billingPeriodDisplay}
+
+
+
+
+ {/* Right: Actions */}
+
+ {isOverQuota && overageDisplay && (
+
+ {t('quota.storageBanner.overage', '+{{size}} @ $0.50/GB', {
+ size: overageDisplay,
+ })}
+
+ )}
+
+ {t('quota.storageBanner.upgrade', 'Upgrade Plan')}
+
+ {onDismiss && (
+
+ )}
+
+
+
+ {/* Additional info for over-quota */}
+ {isOverQuota && (
+
+ {t(
+ 'quota.storageBanner.overageInfo',
+ 'Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.'
+ )}
+
+ )}
+
+ {/* Last measured timestamp */}
+ {lastMeasuredDisplay && (
+
+ {t('quota.storageBanner.lastMeasured', 'Last measured: {{time}}', {
+ time: lastMeasuredDisplay,
+ })}
+
+ )}
+
+
+ );
+};
+
+export default StorageQuotaBanner;
diff --git a/frontend/src/components/marketing/FeatureComparisonTable.tsx b/frontend/src/components/marketing/FeatureComparisonTable.tsx
index e3b91944..0f8acf07 100644
--- a/frontend/src/components/marketing/FeatureComparisonTable.tsx
+++ b/frontend/src/components/marketing/FeatureComparisonTable.tsx
@@ -52,8 +52,7 @@ const FEATURE_CATEGORIES = [
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
- { code: 'custom_branding', label: 'Custom branding' },
- { code: 'remove_branding', label: 'Remove branding' },
+ { code: 'can_white_label', label: 'White label branding' },
],
},
{
diff --git a/frontend/src/components/platform/DynamicFeaturesEditor.tsx b/frontend/src/components/platform/DynamicFeaturesEditor.tsx
index c71a8208..a853249c 100644
--- a/frontend/src/components/platform/DynamicFeaturesEditor.tsx
+++ b/frontend/src/components/platform/DynamicFeaturesEditor.tsx
@@ -12,6 +12,7 @@
import React, { useMemo } from 'react';
import { Key, AlertCircle } from 'lucide-react';
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
+import { isWipFeature } from '../../billing/featureCatalog';
export interface DynamicFeaturesEditorProps {
/**
@@ -62,6 +63,11 @@ export interface DynamicFeaturesEditorProps {
* Number of columns (default: 3)
*/
columns?: 2 | 3 | 4;
+
+ /**
+ * Disable all inputs (for read-only mode)
+ */
+ disabled?: boolean;
}
const DynamicFeaturesEditor: React.FC = ({
@@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC = ({
headerTitle = 'Features & Permissions',
showDescriptions = false,
columns = 3,
+ disabled = false,
}) => {
const { data: features, isLoading, error } = useBillingFeatures();
@@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC = ({
if (feature.feature_type === 'boolean') {
const isChecked = currentValue === true;
+ const isInputDisabled = isDisabled || disabled;
return (
{/* Stats */}
-
+
Total Staff
@@ -201,8 +266,75 @@ const PlatformStaff: React.FC = () => {
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
+
+ {/* Pending Invitations */}
+ {showInvitations && pendingInvitations.length > 0 && (
+
+
+
+ Pending Invitations
+
+
+ {pendingInvitations.map((inv: PlatformStaffInvitation) => (
+
+
+
+
+
+
+
+ {inv.email}
+
+
+ {inv.role_display} • Invited by {inv.invited_by || 'Unknown'} • Expires{' '}
+ {inv.expires_at
+ ? new Date(inv.expires_at).toLocaleDateString()
+ : 'in 7 days'}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
{/* Staff List */}
@@ -214,9 +346,6 @@ const PlatformStaff: React.FC = () => {
Role
-
- Permissions
-
Status
@@ -254,27 +383,6 @@ const PlatformStaff: React.FC = () => {
{/* Role */}
{getRoleBadge(user.role)}
- {/* Permissions */}
-
-
- {user.permissions?.can_approve_plugins && (
-
- Plugin Approver
-
- )}
- {user.permissions?.can_whitelist_urls && (
-
- URL Whitelister
-
- )}
- {!user.permissions?.can_approve_plugins && !user.permissions?.can_whitelist_urls && (
-
- No special permissions
-
- )}
-
-
-
{/* Status */}
{user.is_active ? (
@@ -341,6 +449,108 @@ const PlatformStaff: React.FC = () => {
user={selectedUser}
/>
)}
+
+ {/* Invite Modal */}
+ {isInviteModalOpen && (
+
+
+ {/* Modal Header */}
+
+
+
+ Invite Staff Member
+
+
+
+
+ {/* Modal Body */}
+
+
+
+ )}
);
};
diff --git a/frontend/src/pages/platform/PlatformStaffInvitePage.tsx b/frontend/src/pages/platform/PlatformStaffInvitePage.tsx
new file mode 100644
index 00000000..1666edf8
--- /dev/null
+++ b/frontend/src/pages/platform/PlatformStaffInvitePage.tsx
@@ -0,0 +1,334 @@
+import React, { useState } from 'react';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import {
+ usePlatformStaffInvitationByToken,
+ useAcceptPlatformStaffInvitation,
+} from '../../hooks/usePlatformStaffInvitations';
+import { useAuth } from '../../hooks/useAuth';
+import {
+ Loader2,
+ CheckCircle,
+ XCircle,
+ Shield,
+ Mail,
+ User,
+ Lock,
+ AlertCircle,
+ Eye,
+ EyeOff,
+} from 'lucide-react';
+
+const PlatformStaffInvitePage: React.FC = () => {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get('token');
+
+ const { data: invitation, isLoading, error } = usePlatformStaffInvitationByToken(token);
+ const acceptMutation = useAcceptPlatformStaffInvitation();
+ const { setTokens } = useAuth();
+
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
+ const [formError, setFormError] = useState('');
+ const [accepted, setAccepted] = useState(false);
+
+ const handleAccept = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setFormError('');
+
+ if (!firstName.trim()) {
+ setFormError(t('platformStaffInvite.firstNameRequired', 'First name is required'));
+ return;
+ }
+
+ if (!password || password.length < 10) {
+ setFormError(t('platformStaffInvite.passwordMinLength', 'Password must be at least 10 characters'));
+ return;
+ }
+
+ if (password !== confirmPassword) {
+ setFormError(t('platformStaffInvite.passwordsMustMatch', 'Passwords do not match'));
+ return;
+ }
+
+ try {
+ const result = await acceptMutation.mutateAsync({
+ token: token!,
+ data: {
+ password,
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ },
+ });
+
+ // Set auth tokens and redirect to platform dashboard
+ if (result.access && result.refresh) {
+ setTokens(result.access, result.refresh);
+ }
+ setAccepted(true);
+
+ // Redirect after a short delay
+ setTimeout(() => {
+ navigate('/platform/dashboard');
+ }, 2000);
+ } catch (err: any) {
+ setFormError(
+ err.response?.data?.detail ||
+ err.response?.data?.error ||
+ t('platformStaffInvite.acceptFailed', 'Failed to accept invitation')
+ );
+ }
+ };
+
+ // No token provided
+ if (!token) {
+ return (
+
+
+
+
+ {t('platformStaffInvite.invalidLink', 'Invalid Invitation Link')}
+
+
+ {t(
+ 'platformStaffInvite.noToken',
+ 'This invitation link is invalid. Please check your email for the correct link.'
+ )}
+
+
+
+ );
+ }
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+
+
+
+ {t('platformStaffInvite.loading', 'Loading invitation...')}
+
+
+
+ );
+ }
+
+ // Error state (invalid/expired token)
+ if (error || !invitation) {
+ return (
+
+
+
+
+ {t('platformStaffInvite.expiredTitle', 'Invitation Expired or Invalid')}
+
+
+ {t(
+ 'platformStaffInvite.expiredDescription',
+ 'This invitation has expired or is no longer valid. Please contact the administrator to request a new invitation.'
+ )}
+
+
+
+ );
+ }
+
+ // Accepted state
+ if (accepted) {
+ return (
+
+
+
+
+ {t('platformStaffInvite.welcomeTitle', 'Welcome to the Platform Team!')}
+
+
+ {t('platformStaffInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
+
+
+
+
+ );
+ }
+
+ // Main acceptance form
+ return (
+
+
+ {/* Header */}
+
+
+
+ {t('platformStaffInvite.title', 'Platform Staff Invitation')}
+
+
+ {t('platformStaffInvite.subtitle', 'Join the SmoothSchedule platform team')}
+
+
+
+ {/* Invitation Details */}
+
+
+
+
+
+
+
+
+ {t('platformStaffInvite.role', 'Role')}
+
+ {invitation.role_display}
+
+
+
+
+
+
+
+
+
+ {t('platformStaffInvite.email', 'Email')}
+
+ {invitation.email}
+
+
+
+ {invitation.role_description && (
+
+ {invitation.role_description}
+
+ )}
+
+ {invitation.personal_message && (
+
+
+ {t('platformStaffInvite.personalMessage', 'Personal Message')}
+
+ "{invitation.personal_message}"
+
+ )}
+
+ {invitation.invited_by && (
+
+ {t('platformStaffInvite.invitedBy', 'Invited by')}{' '}
+ {invitation.invited_by}
+
+ )}
+
+
+
+ {/* Form */}
+
+
+
+ );
+};
+
+export default PlatformStaffInvitePage;
diff --git a/frontend/src/pages/platform/components/BusinessEditModal.tsx b/frontend/src/pages/platform/components/BusinessEditModal.tsx
index b4e1da70..be1dc78c 100644
--- a/frontend/src/pages/platform/components/BusinessEditModal.tsx
+++ b/frontend/src/pages/platform/components/BusinessEditModal.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
-import { X, Save, RefreshCw, AlertCircle } from 'lucide-react';
+import { X, Save, AlertCircle } from 'lucide-react';
import { useUpdateBusiness, useChangeBusinessPlan } from '../../../hooks/usePlatform';
import {
useBillingPlans,
@@ -33,6 +33,9 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
const [loadingCustomTier, setLoadingCustomTier] = useState(false);
const [deletingCustomTier, setDeletingCustomTier] = useState(false);
+ // Toggle for custom features - when true, features are editable and saved to custom tier
+ const [useCustomFeatures, setUseCustomFeatures] = useState(false);
+
// Core form fields (non-feature fields only)
const [editForm, setEditForm] = useState({
name: '',
@@ -118,27 +121,33 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
setFeatureValues(featureDefaults);
};
- // Reset to plan defaults button handler
- const handleResetToPlanDefaults = async () => {
+ // Handle toggling custom features on/off
+ const handleCustomFeaturesToggle = async (enabled: boolean) => {
if (!business) return;
- // If custom tier exists, delete it
- if (customTier) {
- setDeletingCustomTier(true);
- try {
- await deleteCustomTier(business.id);
- setCustomTier(null);
- } catch (error) {
- console.error('Failed to delete custom tier:', error);
+ if (enabled) {
+ // Enable custom features - just toggle the state
+ // Custom tier will be created when saving
+ setUseCustomFeatures(true);
+ } else {
+ // Disable custom features - delete custom tier and reset to plan defaults
+ if (customTier) {
+ setDeletingCustomTier(true);
+ try {
+ await deleteCustomTier(business.id);
+ setCustomTier(null);
+ } catch (error) {
+ console.error('Failed to delete custom tier:', error);
+ setDeletingCustomTier(false);
+ return;
+ }
setDeletingCustomTier(false);
- return;
}
- setDeletingCustomTier(false);
+ setUseCustomFeatures(false);
+ // Reset to plan defaults
+ const featureDefaults = getPlanDefaults(editForm.plan_code);
+ setFeatureValues(featureDefaults);
}
-
- // Reset all feature values to plan defaults (includes limits)
- const featureDefaults = getPlanDefaults(editForm.plan_code);
- setFeatureValues(featureDefaults);
};
// Map tier name/code to plan code
@@ -191,6 +200,7 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
const tier = await getCustomTier(businessId);
console.log('[loadCustomTier] Got tier:', tier ? 'exists' : 'null');
setCustomTier(tier);
+ setUseCustomFeatures(!!tier); // Enable custom features if tier exists
if (tier && billingFeatures) {
// Custom tier exists - load features from custom tier
@@ -227,6 +237,7 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
// 404 means no custom tier exists - this is expected, load plan defaults
console.log('[loadCustomTier] Error (likely 404):', error);
setCustomTier(null);
+ setUseCustomFeatures(false);
if (business) {
const planCode = tierToPlanCode(business.tier);
console.log('[loadCustomTier] Loading plan defaults for:', planCode);
@@ -269,19 +280,25 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
if (!business || !billingFeatures) return;
try {
- // Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
- const featuresForBackend: Record = {};
- for (const feature of billingFeatures) {
- if (!feature.tenant_field_name) continue;
- const value = featureValues[feature.tenant_field_name];
- if (value !== undefined) {
- // Use feature.code as the key for the backend
- featuresForBackend[feature.code] = value;
+ // Only save custom tier if custom features are enabled
+ if (useCustomFeatures) {
+ // Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
+ const featuresForBackend: Record = {};
+ for (const feature of billingFeatures) {
+ if (!feature.tenant_field_name) continue;
+ const value = featureValues[feature.tenant_field_name];
+ if (value !== undefined) {
+ // Use feature.code as the key for the backend
+ featuresForBackend[feature.code] = value;
+ }
}
- }
- // Save feature values to custom tier
- await updateCustomTier(business.id, featuresForBackend);
+ // Save feature values to custom tier
+ await updateCustomTier(business.id, featuresForBackend);
+ } else if (customTier) {
+ // Custom features disabled but tier exists - delete it
+ await deleteCustomTier(business.id);
+ }
// Extract only the fields that the update endpoint accepts (exclude plan_code and feature values)
const { plan_code, ...coreFields } = editForm;
@@ -318,14 +335,14 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
Loading...
- ) : customTier ? (
+ ) : useCustomFeatures ? (
- Custom Tier
+ Custom Features
) : (
- Plan Defaults
+ Plan Features
)}
@@ -374,24 +391,9 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
{/* Subscription Plan */}
-
-
- Subscription Plan
-
-
-
+
+ Subscription Plan
+
- {customTier
- ? 'This business has a custom tier. Feature changes will be saved to the custom tier.'
- : 'Changing plan will auto-update limits and permissions to plan defaults'}
+ Changing plan will auto-update limits and permissions to plan defaults
+ {/* Custom Features Toggle */}
+
+
+
+ Enable Custom Features
+
+
+ {useCustomFeatures
+ ? 'Features below will be saved as a custom tier (overrides plan)'
+ : 'Features below are from the subscription plan (read-only)'}
+
+
+
+
+
{/* Limits & Quotas - Dynamic from billing system */}
= ({ business, isOpen,
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
+ disabled={!useCustomFeatures}
/>
@@ -468,6 +493,7 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
featureType="boolean"
headerTitle="Features & Permissions"
showDescriptions
+ disabled={!useCustomFeatures}
/>
diff --git a/frontend/src/pages/platform/components/EditPlatformUserModal.tsx b/frontend/src/pages/platform/components/EditPlatformUserModal.tsx
index 8f693c95..5096e977 100644
--- a/frontend/src/pages/platform/components/EditPlatformUserModal.tsx
+++ b/frontend/src/pages/platform/components/EditPlatformUserModal.tsx
@@ -4,7 +4,6 @@
* - Basic info (name, email, username)
* - Password reset
* - Role assignment
- * - Permissions (can_approve_plugins, etc.)
* - Account status (active/inactive)
*/
@@ -22,6 +21,9 @@ import {
Eye,
EyeOff,
AlertCircle,
+ Trash2,
+ Archive,
+ Loader2,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../../api/client';
@@ -38,10 +40,6 @@ interface EditPlatformUserModalProps {
last_name: string;
role: string;
is_active: boolean;
- permissions: {
- can_approve_plugins?: boolean;
- [key: string]: any;
- };
};
}
@@ -61,13 +59,6 @@ const EditPlatformUserModal: React.FC = ({
const canEditRole = currentRole === 'superuser' ||
(currentRole === 'platform_manager' && targetRole === 'platform_support');
- // Get available permissions for current user
- // Superusers always have all permissions, others check the permissions field
- const availablePermissions = {
- can_approve_plugins: currentRole === 'superuser' || !!currentUser?.permissions?.can_approve_plugins,
- can_whitelist_urls: currentRole === 'superuser' || !!currentUser?.permissions?.can_whitelist_urls,
- };
-
// Form state
const [formData, setFormData] = useState({
username: user.username,
@@ -78,15 +69,15 @@ const EditPlatformUserModal: React.FC = ({
is_active: user.is_active,
});
- const [permissions, setPermissions] = useState({
- can_approve_plugins: user.permissions?.can_approve_plugins || false,
- can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
- });
-
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState('');
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
+
+ // Check if current user can delete/archive (only superuser, and not themselves)
+ const canDelete = currentRole === 'superuser' && currentUser?.id !== user.id;
// Update mutation
const updateMutation = useMutation({
@@ -100,6 +91,31 @@ const EditPlatformUserModal: React.FC = ({
},
});
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: async () => {
+ await apiClient.delete(`/platform/users/${user.id}/`);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
+ onClose();
+ },
+ });
+
+ // Archive mutation (deactivate user)
+ const archiveMutation = useMutation({
+ mutationFn: async () => {
+ const response = await apiClient.patch(`/platform/users/${user.id}/`, {
+ is_active: false,
+ });
+ return response.data;
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
+ onClose();
+ },
+ });
+
// Reset form when user changes
useEffect(() => {
setFormData({
@@ -110,13 +126,11 @@ const EditPlatformUserModal: React.FC = ({
role: user.role,
is_active: user.is_active,
});
- setPermissions({
- can_approve_plugins: user.permissions?.can_approve_plugins || false,
- can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
- });
setPassword('');
setConfirmPassword('');
setPasswordError('');
+ setShowDeleteConfirm(false);
+ setShowArchiveConfirm(false);
}, [user]);
const handleSubmit = (e: React.FormEvent) => {
@@ -137,7 +151,6 @@ const EditPlatformUserModal: React.FC = ({
// Prepare update data
const updateData: any = {
...formData,
- permissions: permissions,
};
// Only include password if changed
@@ -148,13 +161,6 @@ const EditPlatformUserModal: React.FC = ({
updateMutation.mutate(updateData);
};
- const handlePermissionToggle = (permission: string) => {
- setPermissions((prev) => ({
- ...prev,
- [permission]: !prev[permission as keyof typeof prev],
- }));
- };
-
if (!isOpen) return null;
return (
@@ -293,56 +299,6 @@ const EditPlatformUserModal: React.FC = ({
- {/* Permissions */}
-
-
- Special Permissions
-
-
- {availablePermissions.can_approve_plugins && (
-
- handlePermissionToggle('can_approve_plugins')}
- className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
- />
-
-
- Can Approve Plugins
-
-
- Allow this user to review and approve community plugins for the marketplace
-
-
-
- )}
- {availablePermissions.can_whitelist_urls && (
-
- handlePermissionToggle('can_whitelist_urls')}
- className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
- />
-
-
- Can Whitelist URLs
-
-
- Allow this user to whitelist external URLs for plugin API calls (per-user and platform-wide)
-
-
-
- )}
- {!availablePermissions.can_approve_plugins && !availablePermissions.can_whitelist_urls && (
-
- You don't have any special permissions to grant.
-
- )}
-
-
-
{/* Password Reset */}
@@ -445,22 +401,95 @@ const EditPlatformUserModal: React.FC = ({
)}
{/* Actions */}
-
-
-
+
+ {/* Left side - Destructive actions */}
+
+ {canDelete && !showDeleteConfirm && !showArchiveConfirm && (
+ <>
+
+
+ >
+ )}
+
+ {/* Archive confirmation */}
+ {showArchiveConfirm && (
+
+ Archive this user?
+
+
+
+ )}
+
+ {/* Delete confirmation */}
+ {showDeleteConfirm && (
+
+ Permanently delete?
+
+
+
+ )}
+
+
+ {/* Right side - Save/Cancel */}
+
+
+
+
diff --git a/frontend/src/pages/platform/components/TenantInviteModal.tsx b/frontend/src/pages/platform/components/TenantInviteModal.tsx
index fdea65ce..66bb7f90 100644
--- a/frontend/src/pages/platform/components/TenantInviteModal.tsx
+++ b/frontend/src/pages/platform/components/TenantInviteModal.tsx
@@ -186,17 +186,16 @@ const TenantInviteModal: React.FC = ({ isOpen, onClose }
return {
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
- can_manage_oauth_credentials: getBooleanFeature(features, 'remove_branding') && getBooleanFeature(features, 'api_access'),
+ can_manage_oauth_credentials: getBooleanFeature(features, 'can_white_label') && getBooleanFeature(features, 'api_access'),
can_accept_payments: getBooleanFeature(features, 'payment_processing'),
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
- can_white_label: getBooleanFeature(features, 'remove_branding'),
+ can_white_label: getBooleanFeature(features, 'can_white_label'),
can_api_access: getBooleanFeature(features, 'api_access'),
- can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
can_use_masked_phone_numbers: getBooleanFeature(features, 'masked_calling_enabled'),
- can_use_plugins: true, // Always enabled
- can_use_tasks: true, // Always enabled
- can_create_plugins: getBooleanFeature(features, 'api_access'),
+ can_use_automations: getBooleanFeature(features, 'can_use_automations') ?? true,
+ can_use_tasks: getBooleanFeature(features, 'can_use_tasks') ?? true,
+ can_create_automations: getBooleanFeature(features, 'can_create_automations'),
can_use_webhooks: getBooleanFeature(features, 'integrations_enabled'),
can_use_calendar_sync: getBooleanFeature(features, 'integrations_enabled'),
can_export_data: getBooleanFeature(features, 'advanced_reporting'),
diff --git a/frontend/src/pages/platform/components/__tests__/EditPlatformUserModal.test.tsx b/frontend/src/pages/platform/components/__tests__/EditPlatformUserModal.test.tsx
index 09d9a00e..9d49ef1f 100644
--- a/frontend/src/pages/platform/components/__tests__/EditPlatformUserModal.test.tsx
+++ b/frontend/src/pages/platform/components/__tests__/EditPlatformUserModal.test.tsx
@@ -32,20 +32,12 @@ const mockUser = {
last_name: 'User',
role: 'platform_support',
is_active: true,
- permissions: {
- can_approve_plugins: false,
- can_whitelist_urls: false,
- },
};
const mockSuperuser = {
id: 2,
email: 'admin@example.com',
role: 'superuser',
- permissions: {
- can_approve_plugins: true,
- can_whitelist_urls: true,
- },
};
const createWrapper = () => {
@@ -331,32 +323,6 @@ describe('EditPlatformUserModal', () => {
});
});
- describe('Permissions Section', () => {
- it('shows permissions section for superuser', () => {
- render(
- React.createElement(EditPlatformUserModal, {
- isOpen: true,
- onClose: vi.fn(),
- user: mockUser,
- }),
- { wrapper: createWrapper() }
- );
- expect(screen.getByText('Special Permissions')).toBeInTheDocument();
- });
-
- it('shows can approve plugins permission', () => {
- render(
- React.createElement(EditPlatformUserModal, {
- isOpen: true,
- onClose: vi.fn(),
- user: mockUser,
- }),
- { wrapper: createWrapper() }
- );
- expect(screen.getByText(/Approve Plugins/i)).toBeInTheDocument();
- });
- });
-
describe('Account Status', () => {
it('shows status toggle', () => {
render(
diff --git a/frontend/src/pages/settings/SystemEmailTemplates.tsx b/frontend/src/pages/settings/SystemEmailTemplates.tsx
index 5b7961a0..148cdd44 100644
--- a/frontend/src/pages/settings/SystemEmailTemplates.tsx
+++ b/frontend/src/pages/settings/SystemEmailTemplates.tsx
@@ -253,7 +253,7 @@ const SystemEmailTemplates: React.FC = () => {
...item,
props: {
...item.props,
- id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
+ id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 9341fd19..43096138 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -36,14 +36,12 @@ export interface PlanPermissions {
webhooks: boolean;
api_access: boolean;
custom_domain: boolean;
- custom_branding: boolean;
- remove_branding: boolean;
+ white_label: boolean;
custom_oauth: boolean;
automations: boolean;
can_create_automations: boolean;
tasks: boolean;
export_data: boolean;
- video_conferencing: boolean;
two_factor_auth: boolean;
masked_calling: boolean;
pos_system: boolean;
@@ -1130,4 +1128,58 @@ export interface StaffEmailStats {
count: number;
unread: number;
}[];
+}
+
+// --- Platform Email Template Types (Puck-based, superuser only) ---
+
+export type PlatformEmailType =
+ | 'tenant_invitation'
+ | 'trial_expiration_warning'
+ | 'trial_expired'
+ | 'plan_upgrade'
+ | 'plan_downgrade'
+ | 'subscription_cancelled'
+ | 'payment_failed'
+ | 'payment_succeeded';
+
+export type PlatformEmailCategory =
+ | 'invitation'
+ | 'trial'
+ | 'subscription'
+ | 'billing';
+
+export interface PlatformEmailTemplate {
+ email_type: PlatformEmailType;
+ subject_template: string;
+ puck_data: Record;
+ is_active: boolean;
+ is_customized: boolean;
+ display_name: string;
+ description: string;
+ category: PlatformEmailCategory;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface PlatformEmailTemplateDetail extends PlatformEmailTemplate {
+ available_tags: PlatformEmailTag[];
+ created_by?: number | null;
+ created_by_email?: string | null;
+}
+
+export interface PlatformEmailTag {
+ name: string;
+ description: string;
+ syntax: string;
+}
+
+export interface PlatformEmailTemplatePreview {
+ subject: string;
+ html: string;
+}
+
+export interface PlatformEmailTemplateUpdate {
+ subject_template: string;
+ puck_data: Record;
+ is_active?: boolean;
}
\ No newline at end of file
diff --git a/smoothschedule/.envs/.local/.django b/smoothschedule/.envs/.local/.django
index 6125559d..1d90cd3e 100644
--- a/smoothschedule/.envs/.local/.django
+++ b/smoothschedule/.envs/.local/.django
@@ -39,3 +39,13 @@ MAIL_SERVER_SSH_USER=poduck
MAIL_SERVER_DOCKER_CONTAINER=mailserver
MAIL_SERVER_SSH_KEY_PATH=/app/.ssh/id_ed25519
MAIL_SERVER_SSH_KNOWN_HOSTS_PATH=/app/.ssh/known_hosts
+
+# SMTP Email Configuration
+# ------------------------------------------------------------------------------
+DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
+EMAIL_HOST=mail.talova.net
+EMAIL_PORT=587
+EMAIL_HOST_USER=noreply@smoothschedule.com
+EMAIL_HOST_PASSWORD=chaff/starry
+EMAIL_USE_TLS=True
+DEFAULT_FROM_EMAIL=noreply@smoothschedule.com
diff --git a/smoothschedule/BILLING_PLANS.md b/smoothschedule/BILLING_PLANS.md
index b48464aa..f568ed7d 100644
--- a/smoothschedule/BILLING_PLANS.md
+++ b/smoothschedule/BILLING_PLANS.md
@@ -54,9 +54,7 @@ Annual pricing = ~10x monthly (2 months free)
| `advanced_reporting` | - | - | - | Yes | Yes |
| `team_permissions` | - | - | - | Yes | Yes |
| `audit_logs` | - | - | - | Yes | Yes |
-| `custom_branding` | - | - | - | Yes | Yes |
-| `white_label` | - | - | - | - | Yes |
-| `remove_branding` | - | - | - | - | Yes |
+| `can_white_label` | - | - | - | Yes | Yes |
| `multi_location` | - | - | - | - | Yes |
| `priority_support` | - | - | - | - | Yes |
| `dedicated_account_manager` | - | - | - | - | Yes |
@@ -88,7 +86,7 @@ Annual pricing = ~10x monthly (2 months free)
| **Advanced Reporting** | $15/mo, $150/yr | Enables `advanced_reporting` | No | Starter, Growth |
| **API Access** | $20/mo, $200/yr | Enables `api_access`, 5K API calls/day | No | Starter, Growth |
| **Masked Calling** | $39/mo, $390/yr | Enables `masked_calling_enabled` | No | Starter, Growth |
-| **White Label** | $99/mo, $990/yr | Enables `white_label`, `remove_branding`, `custom_branding` | No | Pro only |
+| **White Label** | $99/mo, $990/yr | Enables `can_white_label` (custom branding + remove branding) | No | Pro only |
**Stackable add-ons:** Integer values multiply by quantity purchased.
@@ -330,15 +328,13 @@ The system seeds 30 features (20 boolean, 10 integer):
| `email_enabled` | Can send email notifications |
| `masked_calling_enabled` | Can use masked phone calls |
| `api_access` | Can access REST API |
-| `custom_branding` | Can customize branding |
-| `remove_branding` | Can remove "Powered by" |
| `custom_domain` | Can use custom domain |
+| `can_white_label` | Customize branding and remove "Powered by" |
| `multi_location` | Can manage multiple locations |
| `advanced_reporting` | Access to analytics dashboard |
| `priority_support` | Priority support queue |
| `dedicated_account_manager` | Has dedicated AM |
| `sla_guarantee` | SLA commitments |
-| `white_label` | Full white-label capabilities |
| `team_permissions` | Granular team permissions |
| `audit_logs` | Access to audit logs |
| `integrations_enabled` | Can use third-party integrations |
diff --git a/smoothschedule/compose/local/django/celery/worker/start b/smoothschedule/compose/local/django/celery/worker/start
index 183a8015..34b4ebd2 100644
--- a/smoothschedule/compose/local/django/celery/worker/start
+++ b/smoothschedule/compose/local/django/celery/worker/start
@@ -4,4 +4,4 @@ set -o errexit
set -o nounset
-exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO'
+exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO --pool=solo'
diff --git a/smoothschedule/config/settings/local.py b/smoothschedule/config/settings/local.py
index 2969948f..d271f332 100644
--- a/smoothschedule/config/settings/local.py
+++ b/smoothschedule/config/settings/local.py
@@ -92,6 +92,12 @@ CACHES = {
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend",
)
+EMAIL_HOST = env("EMAIL_HOST", default="")
+EMAIL_PORT = env.int("EMAIL_PORT", default=587)
+EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="")
+EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", default="")
+EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True)
+DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL", default="noreply@smoothschedule.com")
# WhiteNoise
# ------------------------------------------------------------------------------
@@ -109,7 +115,9 @@ EMAIL_BACKEND = env(
INSTALLED_APPS += ["django_extensions"]
# CELERY
# ------------------------------------------------------------------------------
-
+# Run tasks synchronously in development (no need for celery worker)
+# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-always-eager
+CELERY_TASK_ALWAYS_EAGER = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
CELERY_TASK_EAGER_PROPAGATES = True
diff --git a/smoothschedule/smoothschedule/billing/api/urls.py b/smoothschedule/smoothschedule/billing/api/urls.py
index fef1f9d3..79092daf 100644
--- a/smoothschedule/smoothschedule/billing/api/urls.py
+++ b/smoothschedule/smoothschedule/billing/api/urls.py
@@ -12,6 +12,8 @@ from smoothschedule.billing.api.views import (
InvoiceDetailView,
InvoiceListView,
PlanCatalogView,
+ QuotaStatusView,
+ QuotaDismissBannerView,
# Admin ViewSets
FeatureViewSet,
PlanViewSet,
@@ -32,6 +34,12 @@ urlpatterns = [
# /api/me/ endpoints (current user/business context)
path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
+ path("me/quota/", QuotaStatusView.as_view(), name="me-quota"),
+ path(
+ "me/quota/dismiss-banner/",
+ QuotaDismissBannerView.as_view(),
+ name="me-quota-dismiss-banner",
+ ),
# /api/billing/ endpoints (public catalog)
path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
diff --git a/smoothschedule/smoothschedule/billing/api/views.py b/smoothschedule/smoothschedule/billing/api/views.py
index c3c7d130..162620a8 100644
--- a/smoothschedule/smoothschedule/billing/api/views.py
+++ b/smoothschedule/smoothschedule/billing/api/views.py
@@ -36,6 +36,8 @@ from smoothschedule.billing.models import (
Subscription,
)
from smoothschedule.billing.services.entitlements import EntitlementService
+from smoothschedule.billing.services.quota import QuotaService
+from smoothschedule.billing.services.storage import StorageService
from smoothschedule.platform.admin.permissions import IsPlatformAdmin
@@ -57,6 +59,114 @@ class EntitlementsView(APIView):
return Response(entitlements)
+class QuotaStatusView(APIView):
+ """
+ GET /api/me/quota/
+
+ Returns the current business's quota usage status including:
+ - Appointment quota (monthly, billing cycle based)
+ - Flow execution count (monthly)
+ - API request quota (daily)
+ - Warning banner visibility
+
+ POST /api/me/quota/dismiss-banner/
+
+ Dismisses the quota warning banner for the current user/month.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response(
+ {"detail": "No tenant context"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Get monthly quota status (appointments, flow executions)
+ quota_status = QuotaService.get_quota_status(
+ tenant, user=request.user
+ )
+
+ # Get daily API usage
+ api_usage = QuotaService.get_api_usage_status(tenant)
+
+ # Get storage status
+ storage_status = StorageService.get_storage_status(tenant)
+
+ return Response({
+ # Billing period
+ "billing_period": {
+ "year": quota_status.year,
+ "month": quota_status.month,
+ },
+ # Appointment quota
+ "appointments": {
+ "count": quota_status.appointment_count,
+ "limit": quota_status.quota_limit,
+ "is_unlimited": quota_status.is_unlimited,
+ "usage_percentage": round(quota_status.usage_percentage, 1),
+ "remaining": quota_status.remaining_appointments,
+ "is_at_warning_threshold": quota_status.is_at_warning_threshold,
+ "is_over_quota": quota_status.is_over_quota,
+ "overage_count": quota_status.overage_count,
+ "overage_amount_cents": quota_status.overage_amount_cents,
+ },
+ # Flow executions
+ "flow_executions": {
+ "count": quota_status.flow_execution_count,
+ "amount_cents": quota_status.flow_execution_amount_cents,
+ },
+ # API requests (daily)
+ "api_requests": api_usage,
+ # Storage quota
+ "storage": {
+ "current_size_mb": round(storage_status.current_size_mb, 2),
+ "current_size_gb": round(storage_status.current_size_gb, 3),
+ "peak_size_mb": round(storage_status.peak_size_mb, 2),
+ "quota_limit_mb": storage_status.quota_limit_mb,
+ "quota_limit_gb": round(storage_status.quota_limit_gb, 2),
+ "is_unlimited": storage_status.is_unlimited,
+ "usage_percentage": round(storage_status.usage_percentage, 1),
+ "remaining_mb": round(storage_status.remaining_mb, 2) if storage_status.remaining_mb else None,
+ "is_at_warning_threshold": storage_status.is_at_warning_threshold,
+ "is_over_quota": storage_status.is_over_quota,
+ "overage_mb": round(storage_status.overage_mb, 2),
+ "overage_amount_cents": storage_status.overage_amount_cents,
+ "warning_email_sent": storage_status.warning_email_sent,
+ "last_measured_at": storage_status.last_measured_at,
+ },
+ # Warning state
+ "warning": {
+ "show_banner": quota_status.show_warning_banner,
+ "email_sent": quota_status.warning_email_sent,
+ },
+ })
+
+
+class QuotaDismissBannerView(APIView):
+ """
+ POST /api/me/quota/dismiss-banner/
+
+ Dismisses the quota warning banner for the current user/billing period.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response(
+ {"detail": "No tenant context"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ QuotaService.dismiss_warning_banner(request.user, tenant)
+
+ return Response({"success": True, "message": "Banner dismissed"})
+
+
class CurrentSubscriptionView(APIView):
"""
GET /api/me/subscription/
diff --git a/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py b/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py
index f6943b93..9530be53 100644
--- a/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py
+++ b/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py
@@ -66,9 +66,8 @@ FEATURES = [
# --- Customization ---
{"code": "online_booking", "name": "Online Booking", "description": "Allow customers to book appointments online", "feature_type": "boolean", "category": "customization", "tenant_field_name": "online_booking", "display_order": 10},
- {"code": "custom_branding", "name": "Custom Branding", "description": "Customize branding colors, logo, and styling", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_customize_booking_page", "display_order": 20},
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
- {"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
+ {"code": "can_white_label", "name": "White Label", "description": "Customize branding and remove SmoothSchedule branding", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
# --- Automations ---
@@ -77,25 +76,50 @@ FEATURES = [
{"code": "can_create_automations", "name": "Create Automations", "description": "Build custom automations", "feature_type": "boolean", "category": "automations", "tenant_field_name": "can_create_automations", "display_order": 30, "depends_on": "can_use_automations"},
# --- Advanced Features ---
+ # TODO: Implement api_access enforcement - Apply HasFeaturePermission('api_access') to public API v1 views
+ # in platform/api/views.py to block API access for tenants without this feature.
{"code": "api_access", "name": "API Access", "description": "Access the public API for integrations", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_api_access", "display_order": 10},
{"code": "integrations_enabled", "name": "Integrations", "description": "Connect with third-party services and apps", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_webhooks", "display_order": 20},
{"code": "can_use_calendar_sync", "name": "Calendar Sync", "description": "Sync with Google Calendar, etc.", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_calendar_sync", "display_order": 30},
+ # TODO: Implement can_export_data enforcement - Block data export endpoints (CSV/Excel export)
+ # for tenants without this feature. Add HasFeaturePermission('can_export_data') to export views.
{"code": "can_export_data", "name": "Data Export", "description": "Export data to CSV/Excel", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_export_data", "display_order": 40},
- {"code": "can_add_video_conferencing", "name": "Video Conferencing", "description": "Add video links to appointments", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_add_video_conferencing", "display_order": 50},
+ # TODO: Implement custom_fields feature - Allow services to have custom intake fields that customers
+ # fill out during booking. Create CustomField model, link to Service, display in booking flow.
+ {"code": "custom_fields", "name": "Custom Fields", "description": "Add custom intake fields to services for customer booking", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_custom_fields", "display_order": 50},
{"code": "advanced_reporting", "name": "Advanced Analytics", "description": "Detailed reporting and analytics", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "advanced_reporting", "display_order": 60},
{"code": "can_use_contracts", "name": "Contracts", "description": "Create and manage e-signature contracts", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_contracts", "display_order": 70},
+ # TODO: Implement mobile_app_access enforcement - Block mobile app authentication for tenants
+ # without this feature. Check feature in mobile app login endpoint.
{"code": "mobile_app_access", "name": "Mobile App", "description": "Access the mobile app for on-the-go management", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_use_mobile_app", "display_order": 80},
+ # TODO: Implement audit_logs/can_download_logs enforcement - Block audit log download endpoint
+ # for tenants without this feature. Add HasFeaturePermission to audit log export view.
{"code": "audit_logs", "name": "Audit Logs", "description": "Track all changes with detailed audit logs", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "can_download_logs", "display_order": 90},
{"code": "multi_location", "name": "Multi-Location", "description": "Manage multiple business locations", "feature_type": "boolean", "category": "advanced", "tenant_field_name": "multi_location", "display_order": 100},
+ # --- Communication (Advanced) ---
+ # TODO: Implement proxy_number_enabled feature - Assign dedicated phone numbers to staff members
+ # for customer communication. Integrate with Twilio to provision and manage proxy numbers.
+ {"code": "proxy_number_enabled", "name": "Proxy Phone Numbers", "description": "Assign dedicated phone numbers to staff for customer communication", "feature_type": "boolean", "category": "communication", "tenant_field_name": "can_use_proxy_numbers", "display_order": 50},
+
# --- Scheduling ---
+ # TODO: Implement recurring_appointments enforcement - Block creating recurring events for tenants
+ # without this feature. Check feature in Event create/update when recurrence_rule is set.
{"code": "recurring_appointments", "name": "Recurring Appointments", "description": "Schedule recurring appointments", "feature_type": "boolean", "category": "scheduling", "tenant_field_name": "can_book_repeated_events", "display_order": 10},
# --- Enterprise & Security ---
{"code": "can_manage_oauth", "name": "Manage OAuth", "description": "Configure custom OAuth credentials", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_manage_oauth_credentials", "display_order": 10},
- {"code": "team_permissions", "name": "Team Permissions", "description": "Advanced role-based permissions for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_require_2fa", "display_order": 20},
- {"code": "sla_guarantee", "name": "SSO / SAML", "description": "Single sign-on integration", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sso_enabled", "display_order": 30},
+ # TODO: Implement can_require_2fa enforcement - Allow tenant admins to require 2FA for all team
+ # members. Add settings UI and enforce 2FA on login for affected users.
+ {"code": "can_require_2fa", "name": "Require 2FA", "description": "Require two-factor authentication for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "can_require_2fa", "display_order": 20},
+ # TODO: Implement sso_enabled feature - Enable SSO/SAML authentication for enterprise tenants.
+ # Integrate with SAML provider, add SSO configuration UI, handle SSO login flow.
+ {"code": "sso_enabled", "name": "Single Sign-On (SSO)", "description": "Enable SSO/SAML authentication for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sso_enabled", "display_order": 30},
+ # TODO: Implement priority_support feature - Show priority indicator on support tickets for
+ # tenants with this feature. Update TicketSerializer to include priority_support flag.
{"code": "priority_support", "name": "Priority Support", "description": "Get priority customer support response", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "priority_support", "display_order": 40},
+ {"code": "team_permissions", "name": "Team Permissions", "description": "Advanced role-based permissions for team members", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "team_permissions", "display_order": 50},
+ {"code": "sla_guarantee", "name": "SLA Guarantee", "description": "Service level agreement guarantee for uptime and support", "feature_type": "boolean", "category": "enterprise", "tenant_field_name": "sla_guarantee", "display_order": 60},
]
@@ -252,7 +276,7 @@ PLANS = [
"advanced_reporting": True,
"team_permissions": True,
"audit_logs": True,
- "custom_branding": True,
+ "can_white_label": True,
"can_use_email_templates": True,
"can_use_automations": True,
"can_use_tasks": True,
@@ -260,7 +284,6 @@ PLANS = [
"can_process_refunds": True,
"can_use_calendar_sync": True,
"can_export_data": True,
- "can_add_video_conferencing": True,
"can_create_packages": True,
"can_use_pos": True,
"can_use_contracts": True,
@@ -308,8 +331,7 @@ PLANS = [
"advanced_reporting": True,
"team_permissions": True,
"audit_logs": True,
- "custom_branding": True,
- "remove_branding": True,
+ "can_white_label": True,
"multi_location": True,
"priority_support": True,
"sla_guarantee": True,
@@ -320,7 +342,6 @@ PLANS = [
"can_process_refunds": True,
"can_use_calendar_sync": True,
"can_export_data": True,
- "can_add_video_conferencing": True,
"can_create_packages": True,
"can_use_pos": True,
"can_use_contracts": True,
@@ -407,14 +428,14 @@ ADDONS = [
],
},
{
- "code": "remove_branding_addon",
- "name": "Remove Branding",
- "description": "Remove all SmoothSchedule branding from customer-facing pages",
+ "code": "white_label_addon",
+ "name": "White Label",
+ "description": "Customize branding and remove SmoothSchedule branding from customer-facing pages",
"price_monthly_cents": 9900,
"price_one_time_cents": 0, # Recurring only
"is_stackable": False,
"features": [
- {"code": "remove_branding", "bool_value": True},
+ {"code": "can_white_label", "bool_value": True},
],
},
]
diff --git a/smoothschedule/smoothschedule/billing/management/commands/setup_billing_tasks.py b/smoothschedule/smoothschedule/billing/management/commands/setup_billing_tasks.py
index 03e7a418..ac942f68 100644
--- a/smoothschedule/smoothschedule/billing/management/commands/setup_billing_tasks.py
+++ b/smoothschedule/smoothschedule/billing/management/commands/setup_billing_tasks.py
@@ -12,12 +12,13 @@ class Command(BaseCommand):
help = 'Set up periodic Celery Beat tasks for billing operations'
def handle(self, *args, **options):
- from django_celery_beat.models import PeriodicTask, CrontabSchedule
+ from django_celery_beat.models import PeriodicTask, CrontabSchedule, IntervalSchedule
self.stdout.write('Setting up billing periodic tasks...')
- # Create crontab schedule
- # Daily at 1 AM - check custom tier grace periods
+ # =================================================================
+ # Schedule: Daily at 1 AM - check custom tier grace periods
+ # =================================================================
schedule_1am, _ = CrontabSchedule.objects.get_or_create(
minute='0',
hour='1',
@@ -26,12 +27,12 @@ class Command(BaseCommand):
month_of_year='*',
)
- # Create periodic task
task, created = PeriodicTask.objects.update_or_create(
name='billing-check-grace-periods',
defaults={
'task': 'smoothschedule.billing.tasks.check_subscription_grace_periods',
'crontab': schedule_1am,
+ 'interval': None,
'description': 'Check custom tier grace periods and manage subscription lapses (runs daily at 1 AM)',
'enabled': True,
}
@@ -40,9 +41,35 @@ class Command(BaseCommand):
status = 'Created' if created else 'Updated'
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
+ # =================================================================
+ # Schedule: Every hour - measure tenant storage
+ # =================================================================
+ schedule_hourly, _ = IntervalSchedule.objects.get_or_create(
+ every=1,
+ period=IntervalSchedule.HOURS,
+ )
+
+ task, created = PeriodicTask.objects.update_or_create(
+ name='billing-measure-storage',
+ defaults={
+ 'task': 'smoothschedule.billing.tasks.measure_all_tenant_storage',
+ 'interval': schedule_hourly,
+ 'crontab': None,
+ 'description': 'Measure database storage for all tenants (runs hourly)',
+ 'enabled': True,
+ }
+ )
+
+ status = 'Created' if created else 'Updated'
+ self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
+
self.stdout.write(self.style.SUCCESS('\nBilling tasks set up successfully!'))
self.stdout.write('\nTasks configured:')
self.stdout.write(' - billing-check-grace-periods: Daily at 1 AM')
self.stdout.write(' - Clears grace period when subscription becomes active')
self.stdout.write(' - Starts grace period when subscription becomes inactive')
self.stdout.write(' - Deletes custom tiers after 30-day grace period expires')
+ self.stdout.write(' - billing-measure-storage: Every hour')
+ self.stdout.write(' - Measures PostgreSQL schema sizes for each tenant')
+ self.stdout.write(' - Updates StorageUsage records with current measurements')
+ self.stdout.write(' - Sends warning emails at 90% threshold')
diff --git a/smoothschedule/smoothschedule/billing/migrations/0014_add_quota_tracking.py b/smoothschedule/smoothschedule/billing/migrations/0014_add_quota_tracking.py
new file mode 100644
index 00000000..57d3b524
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0014_add_quota_tracking.py
@@ -0,0 +1,55 @@
+# Generated by Django 5.2.8 on 2025-12-30 18:00
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0013_add_tenant_custom_tier'),
+ ('core', '0031_add_cancellation_policy_fields'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='MonthlyQuotaUsage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.PositiveIntegerField()),
+ ('month', models.PositiveIntegerField()),
+ ('appointment_count', models.PositiveIntegerField(default=0)),
+ ('quota_limit', models.PositiveIntegerField(default=0, help_text='Quota limit at the time (0 = unlimited)')),
+ ('overage_count', models.PositiveIntegerField(default=0, help_text='Number of appointments over the quota')),
+ ('overage_billed', models.BooleanField(default=False, help_text='Whether overages have been billed')),
+ ('warning_email_sent', models.BooleanField(default=False, help_text='Whether the 90% quota warning email was sent')),
+ ('warning_email_sent_at', models.DateTimeField(blank=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_usages', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Monthly Quota Usage',
+ 'verbose_name_plural': 'Monthly Quota Usages',
+ 'ordering': ['-year', '-month'],
+ 'unique_together': {('business', 'year', 'month')},
+ },
+ ),
+ migrations.CreateModel(
+ name='QuotaBannerDismissal',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.PositiveIntegerField()),
+ ('month', models.PositiveIntegerField()),
+ ('dismissed_at', models.DateTimeField(auto_now_add=True)),
+ ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_banner_dismissals', to='core.tenant')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_banner_dismissals', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-dismissed_at'],
+ 'unique_together': {('user', 'business', 'year', 'month')},
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0015_add_flow_execution_tracking.py b/smoothschedule/smoothschedule/billing/migrations/0015_add_flow_execution_tracking.py
new file mode 100644
index 00000000..8fd407fa
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0015_add_flow_execution_tracking.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.8 on 2025-12-30 18:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0014_add_quota_tracking'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='monthlyquotausage',
+ name='flow_execution_count',
+ field=models.PositiveIntegerField(default=0, help_text='Number of automation flow executions (excludes UI testing)'),
+ ),
+ migrations.AddField(
+ model_name='monthlyquotausage',
+ name='flow_executions_billed',
+ field=models.BooleanField(default=False, help_text='Whether flow executions have been billed'),
+ ),
+ migrations.AlterField(
+ model_name='monthlyquotausage',
+ name='overage_billed',
+ field=models.BooleanField(default=False, help_text='Whether appointment overages have been billed'),
+ ),
+ migrations.AlterField(
+ model_name='monthlyquotausage',
+ name='quota_limit',
+ field=models.PositiveIntegerField(default=0, help_text='Appointment quota limit at the time (0 = unlimited)'),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0016_add_daily_api_usage.py b/smoothschedule/smoothschedule/billing/migrations/0016_add_daily_api_usage.py
new file mode 100644
index 00000000..d36f3ba2
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0016_add_daily_api_usage.py
@@ -0,0 +1,34 @@
+# Generated by Django 5.2.8 on 2025-12-30 18:06
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0015_add_flow_execution_tracking'),
+ ('core', '0031_add_cancellation_policy_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DailyApiUsage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('date', models.DateField()),
+ ('request_count', models.PositiveIntegerField(default=0)),
+ ('quota_limit', models.PositiveIntegerField(default=0, help_text='API request quota limit at the time (0 = unlimited)')),
+ ('limit_reached_notified', models.BooleanField(default=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_api_usages', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Daily API Usage',
+ 'verbose_name_plural': 'Daily API Usages',
+ 'ordering': ['-date'],
+ 'unique_together': {('business', 'date')},
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0017_add_storage_usage.py b/smoothschedule/smoothschedule/billing/migrations/0017_add_storage_usage.py
new file mode 100644
index 00000000..92769031
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0017_add_storage_usage.py
@@ -0,0 +1,39 @@
+# Generated by Django 5.2.8 on 2025-12-30 18:48
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0016_add_daily_api_usage'),
+ ('core', '0031_add_cancellation_policy_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='StorageUsage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('year', models.PositiveIntegerField()),
+ ('month', models.PositiveIntegerField()),
+ ('current_size_bytes', models.BigIntegerField(default=0, help_text='Current storage usage in bytes')),
+ ('peak_size_bytes', models.BigIntegerField(default=0, help_text='Peak storage usage during this billing period (for billing)')),
+ ('quota_limit_mb', models.PositiveIntegerField(default=0, help_text='Storage quota limit in MB (0 = unlimited)')),
+ ('table_sizes', models.JSONField(blank=True, default=dict, help_text='Breakdown of storage by table name')),
+ ('warning_email_sent', models.BooleanField(default=False)),
+ ('warning_email_sent_at', models.DateTimeField(blank=True, null=True)),
+ ('last_measured_at', models.DateTimeField(blank=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storage_usages', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Storage Usage',
+ 'verbose_name_plural': 'Storage Usages',
+ 'ordering': ['-year', '-month'],
+ 'unique_together': {('business', 'year', 'month')},
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/models.py b/smoothschedule/smoothschedule/billing/models.py
index bb0c6087..e31e3220 100644
--- a/smoothschedule/smoothschedule/billing/models.py
+++ b/smoothschedule/smoothschedule/billing/models.py
@@ -6,6 +6,7 @@ This enables centralized plan management and simpler queries.
"""
from datetime import timedelta
+from decimal import Decimal
from django.db import models
from django.utils import timezone
@@ -678,3 +679,427 @@ class TenantCustomTier(models.Model):
)
remaining = grace_end - timezone.now()
return max(0, remaining.days)
+
+
+class MonthlyQuotaUsage(models.Model):
+ """
+ Tracks monthly quota usage per tenant for appointments and automations.
+
+ This model stores the count of scheduled appointments and automation
+ flow executions per month, with warning emails and overage tracking.
+
+ Overage pricing:
+ - Appointments: $0.10 per appointment over the quota limit
+ - Flow executions: $0.005 per execution (no quota, just usage-based billing)
+
+ Warning threshold: 90% of quota (10% remaining).
+ """
+
+ APPOINTMENT_OVERAGE_PRICE_CENTS = 10 # $0.10 per overage appointment
+ FLOW_EXECUTION_PRICE_CENTS = Decimal("0.5") # $0.005 per execution (0.5 cents)
+
+ business = models.ForeignKey(
+ "core.Tenant",
+ on_delete=models.CASCADE,
+ related_name="quota_usages",
+ )
+
+ # Year and month for this record
+ year = models.PositiveIntegerField()
+ month = models.PositiveIntegerField()
+
+ # Appointment usage tracking
+ appointment_count = models.PositiveIntegerField(default=0)
+ quota_limit = models.PositiveIntegerField(
+ default=0,
+ help_text="Appointment quota limit at the time (0 = unlimited)"
+ )
+
+ # Appointment overage tracking
+ overage_count = models.PositiveIntegerField(
+ default=0,
+ help_text="Number of appointments over the quota"
+ )
+ overage_billed = models.BooleanField(
+ default=False,
+ help_text="Whether appointment overages have been billed"
+ )
+
+ # Automation flow execution tracking
+ # Note: Flow executions don't have a quota, but are billed per execution
+ # Testing through UI should NOT count against this
+ flow_execution_count = models.PositiveIntegerField(
+ default=0,
+ help_text="Number of automation flow executions (excludes UI testing)"
+ )
+ flow_executions_billed = models.BooleanField(
+ default=False,
+ help_text="Whether flow executions have been billed"
+ )
+
+ # Warning email tracking
+ warning_email_sent = models.BooleanField(
+ default=False,
+ help_text="Whether the 90% quota warning email was sent"
+ )
+ warning_email_sent_at = models.DateTimeField(null=True, blank=True)
+
+ # Banner dismissal tracking (stored per user in separate model)
+ # This tracks if the tenant has dismissed the banner for this month
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ["business", "year", "month"]
+ ordering = ["-year", "-month"]
+ verbose_name = "Monthly Quota Usage"
+ verbose_name_plural = "Monthly Quota Usages"
+
+ def __str__(self):
+ return f"{self.business.name} - {self.year}/{self.month:02d}: {self.appointment_count}/{self.quota_limit or '∞'}"
+
+ @property
+ def is_unlimited(self) -> bool:
+ """Check if this tenant has unlimited appointments."""
+ return self.quota_limit == 0
+
+ @property
+ def usage_percentage(self) -> float:
+ """Get usage as a percentage of quota (0-100+)."""
+ if self.is_unlimited:
+ return 0.0
+ return (self.appointment_count / self.quota_limit) * 100
+
+ @property
+ def is_at_warning_threshold(self) -> bool:
+ """Check if usage is at or above 90% warning threshold."""
+ if self.is_unlimited:
+ return False
+ return self.usage_percentage >= 90
+
+ @property
+ def is_over_quota(self) -> bool:
+ """Check if usage is over the quota limit."""
+ if self.is_unlimited:
+ return False
+ return self.appointment_count > self.quota_limit
+
+ @property
+ def remaining_appointments(self) -> int | None:
+ """Get remaining appointments before hitting quota. None if unlimited."""
+ if self.is_unlimited:
+ return None
+ return max(0, self.quota_limit - self.appointment_count)
+
+ @property
+ def overage_amount_cents(self) -> int:
+ """Calculate overage charges in cents."""
+ return self.overage_count * self.APPOINTMENT_OVERAGE_PRICE_CENTS
+
+
+class QuotaBannerDismissal(models.Model):
+ """
+ Tracks when users dismiss the quota warning banner.
+
+ Users can dismiss the banner once per month. The banner will
+ reappear in the next month or if quota usage increases significantly.
+ """
+
+ user = models.ForeignKey(
+ "users.User",
+ on_delete=models.CASCADE,
+ related_name="quota_banner_dismissals",
+ )
+ business = models.ForeignKey(
+ "core.Tenant",
+ on_delete=models.CASCADE,
+ related_name="quota_banner_dismissals",
+ )
+
+ # Year and month this dismissal applies to
+ year = models.PositiveIntegerField()
+ month = models.PositiveIntegerField()
+
+ dismissed_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ unique_together = ["user", "business", "year", "month"]
+ ordering = ["-dismissed_at"]
+
+ def __str__(self):
+ return f"{self.user.email} dismissed banner for {self.business.name} - {self.year}/{self.month:02d}"
+
+
+class DailyApiUsage(models.Model):
+ """
+ Tracks daily API request usage per tenant.
+
+ API requests from off-platform (public API) count against the
+ max_api_requests_per_day quota. This is a daily limit that resets
+ at midnight UTC.
+ """
+
+ business = models.ForeignKey(
+ "core.Tenant",
+ on_delete=models.CASCADE,
+ related_name="daily_api_usages",
+ )
+
+ # Date for this record (UTC)
+ date = models.DateField()
+
+ # API request count
+ request_count = models.PositiveIntegerField(default=0)
+
+ # Quota limit at the time (0 = unlimited)
+ quota_limit = models.PositiveIntegerField(
+ default=0,
+ help_text="API request quota limit at the time (0 = unlimited)"
+ )
+
+ # Whether tenant has been notified of reaching quota today
+ limit_reached_notified = models.BooleanField(default=False)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ["business", "date"]
+ ordering = ["-date"]
+ verbose_name = "Daily API Usage"
+ verbose_name_plural = "Daily API Usages"
+
+ def __str__(self):
+ return f"{self.business.name} - {self.date}: {self.request_count}/{self.quota_limit or '∞'}"
+
+ @property
+ def is_unlimited(self) -> bool:
+ """Check if this tenant has unlimited API requests."""
+ return self.quota_limit == 0
+
+ @property
+ def is_over_quota(self) -> bool:
+ """Check if usage is over the quota limit."""
+ if self.is_unlimited:
+ return False
+ return self.request_count >= self.quota_limit
+
+ @property
+ def remaining_requests(self) -> int | None:
+ """Get remaining requests before hitting quota. None if unlimited."""
+ if self.is_unlimited:
+ return None
+ return max(0, self.quota_limit - self.request_count)
+
+ @property
+ def usage_percentage(self) -> float:
+ """Get usage as a percentage of quota (0-100+)."""
+ if self.is_unlimited:
+ return 0.0
+ return (self.request_count / self.quota_limit) * 100
+
+
+class StorageUsage(models.Model):
+ """
+ Tracks database storage usage per tenant.
+
+ Storage is measured periodically by a Celery task that queries
+ PostgreSQL's pg_total_relation_size for the tenant's schema.
+
+ Storage quotas are monthly limits (like appointments) based on billing cycle.
+ Overage charges apply when usage exceeds the plan limit.
+ """
+
+ # Overage pricing: $0.50 per GB per month over limit
+ STORAGE_OVERAGE_PRICE_CENTS_PER_GB = 50
+
+ business = models.ForeignKey(
+ "core.Tenant",
+ on_delete=models.CASCADE,
+ related_name="storage_usages",
+ )
+
+ # Billing period (year/month based on subscription.current_period_start)
+ year = models.PositiveIntegerField()
+ month = models.PositiveIntegerField()
+
+ # Storage measurements in bytes
+ current_size_bytes = models.BigIntegerField(
+ default=0,
+ help_text="Current storage usage in bytes"
+ )
+ peak_size_bytes = models.BigIntegerField(
+ default=0,
+ help_text="Peak storage usage during this billing period (for billing)"
+ )
+
+ # Quota limit in MB (0 = unlimited)
+ quota_limit_mb = models.PositiveIntegerField(
+ default=0,
+ help_text="Storage quota limit in MB (0 = unlimited)"
+ )
+
+ # Table-level breakdown (JSON for detailed reporting)
+ table_sizes = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Breakdown of storage by table name"
+ )
+
+ # Warning/notification tracking
+ warning_email_sent = models.BooleanField(default=False)
+ warning_email_sent_at = models.DateTimeField(null=True, blank=True)
+
+ # Measurement timestamps
+ last_measured_at = models.DateTimeField(null=True, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = ["business", "year", "month"]
+ ordering = ["-year", "-month"]
+ verbose_name = "Storage Usage"
+ verbose_name_plural = "Storage Usages"
+
+ def __str__(self):
+ return f"{self.business.name} - {self.year}/{self.month:02d}: {self.current_size_mb:.1f}MB/{self.quota_limit_mb or '∞'}MB"
+
+ # =========================================================================
+ # Size Conversion Properties
+ # =========================================================================
+
+ @property
+ def current_size_mb(self) -> float:
+ """Current storage in megabytes."""
+ return self.current_size_bytes / (1024 * 1024)
+
+ @property
+ def current_size_gb(self) -> float:
+ """Current storage in gigabytes."""
+ return self.current_size_bytes / (1024 * 1024 * 1024)
+
+ @property
+ def peak_size_mb(self) -> float:
+ """Peak storage in megabytes."""
+ return self.peak_size_bytes / (1024 * 1024)
+
+ @property
+ def peak_size_gb(self) -> float:
+ """Peak storage in gigabytes."""
+ return self.peak_size_bytes / (1024 * 1024 * 1024)
+
+ @property
+ def quota_limit_bytes(self) -> int:
+ """Quota limit in bytes."""
+ return self.quota_limit_mb * 1024 * 1024
+
+ @property
+ def quota_limit_gb(self) -> float:
+ """Quota limit in gigabytes."""
+ return self.quota_limit_mb / 1024
+
+ # =========================================================================
+ # Quota Status Properties
+ # =========================================================================
+
+ @property
+ def is_unlimited(self) -> bool:
+ """Check if this tenant has unlimited storage."""
+ return self.quota_limit_mb == 0
+
+ @property
+ def is_over_quota(self) -> bool:
+ """Check if current usage is over the quota limit."""
+ if self.is_unlimited:
+ return False
+ return self.current_size_bytes > self.quota_limit_bytes
+
+ @property
+ def is_at_warning_threshold(self) -> bool:
+ """Check if usage is at or above 90% warning threshold."""
+ if self.is_unlimited:
+ return False
+ return self.usage_percentage >= 90
+
+ @property
+ def remaining_mb(self) -> float | None:
+ """Get remaining storage in MB before hitting quota. None if unlimited."""
+ if self.is_unlimited:
+ return None
+ remaining_bytes = max(0, self.quota_limit_bytes - self.current_size_bytes)
+ return remaining_bytes / (1024 * 1024)
+
+ @property
+ def usage_percentage(self) -> float:
+ """Get usage as a percentage of quota (0-100+)."""
+ if self.is_unlimited:
+ return 0.0
+ return (self.current_size_bytes / self.quota_limit_bytes) * 100
+
+ # =========================================================================
+ # Overage Calculation Properties
+ # =========================================================================
+
+ @property
+ def overage_bytes(self) -> int:
+ """Get overage amount in bytes (based on peak usage for billing)."""
+ if self.is_unlimited:
+ return 0
+ return max(0, self.peak_size_bytes - self.quota_limit_bytes)
+
+ @property
+ def overage_mb(self) -> float:
+ """Get overage amount in megabytes."""
+ return self.overage_bytes / (1024 * 1024)
+
+ @property
+ def overage_gb(self) -> float:
+ """Get overage amount in gigabytes."""
+ return self.overage_bytes / (1024 * 1024 * 1024)
+
+ @property
+ def overage_amount_cents(self) -> int:
+ """
+ Calculate overage charge in cents.
+
+ Charges are based on peak usage (not current) to prevent
+ gaming by cleaning up before billing.
+
+ Price: $0.50 per GB over limit per month.
+ """
+ if self.is_unlimited or self.overage_bytes <= 0:
+ return 0
+ # Calculate GB overage and multiply by price
+ overage_gb = self.overage_gb
+ return int(overage_gb * self.STORAGE_OVERAGE_PRICE_CENTS_PER_GB)
+
+ # =========================================================================
+ # Update Methods
+ # =========================================================================
+
+ def update_measurement(self, size_bytes: int, table_sizes: dict | None = None) -> None:
+ """
+ Update storage measurement with new size.
+
+ Args:
+ size_bytes: Current storage size in bytes
+ table_sizes: Optional breakdown by table name
+ """
+ self.current_size_bytes = size_bytes
+ self.last_measured_at = timezone.now()
+
+ # Update peak if current is higher
+ if size_bytes > self.peak_size_bytes:
+ self.peak_size_bytes = size_bytes
+
+ if table_sizes:
+ self.table_sizes = table_sizes
+
+ self.save(update_fields=[
+ "current_size_bytes",
+ "peak_size_bytes",
+ "last_measured_at",
+ "table_sizes",
+ "updated_at",
+ ])
diff --git a/smoothschedule/smoothschedule/billing/services/quota.py b/smoothschedule/smoothschedule/billing/services/quota.py
new file mode 100644
index 00000000..2de16f7d
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/services/quota.py
@@ -0,0 +1,594 @@
+"""
+QuotaService - Manages monthly quota tracking and enforcement.
+
+This service handles:
+- Tracking appointment counts per billing cycle per tenant
+- Tracking automation flow executions per billing cycle
+- Checking if tenants are approaching or exceeding quota
+- Sending warning emails at 90% threshold
+- Calculating overage charges ($0.10 per appointment over quota)
+- Calculating flow execution charges ($0.005 per execution)
+- Managing banner dismissal state
+
+IMPORTANT: "Month" refers to billing cycle, not calendar month.
+The billing cycle is based on the subscription's current_period_start date.
+For example, if a tenant subscribed on Dec 15, their billing cycle is Dec 15 - Jan 14,
+and their "month" for tracking is December 2024 (year=2024, month=12).
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from decimal import Decimal
+from typing import TYPE_CHECKING, NamedTuple
+
+from django.db import transaction
+from django.db.models import F
+from django.utils import timezone
+
+from smoothschedule.billing.models import DailyApiUsage, MonthlyQuotaUsage, QuotaBannerDismissal
+from smoothschedule.billing.services.entitlements import EntitlementService
+
+if TYPE_CHECKING:
+ from smoothschedule.identity.core.models import Tenant
+ from smoothschedule.identity.users.models import User
+
+logger = logging.getLogger(__name__)
+
+
+class QuotaStatus(NamedTuple):
+ """Current quota status for a tenant."""
+ year: int
+ month: int
+ appointment_count: int
+ quota_limit: int # 0 = unlimited
+ is_unlimited: bool
+ usage_percentage: float
+ remaining_appointments: int | None
+ is_at_warning_threshold: bool
+ is_over_quota: bool
+ overage_count: int
+ overage_amount_cents: int
+ warning_email_sent: bool
+ show_warning_banner: bool # Whether to show the banner to this user
+ # Flow execution tracking
+ flow_execution_count: int
+ flow_execution_amount_cents: int
+
+
+class QuotaService:
+ """Service for managing appointment and flow execution quota tracking."""
+
+ FEATURE_CODE = "max_appointments_per_month"
+ WARNING_THRESHOLD_PERCENT = 90
+ OVERAGE_PRICE_CENTS = 10
+ FLOW_EXECUTION_PRICE_CENTS = Decimal("0.5") # $0.005 per execution
+
+ @classmethod
+ def get_current_billing_period(cls, business: Tenant) -> tuple[int, int]:
+ """
+ Get the current billing period (year, month) for a tenant.
+
+ The billing period is based on the subscription's current_period_start.
+ This ensures quota tracking aligns with billing cycles, not calendar months.
+
+ Returns:
+ Tuple of (year, month) representing the billing period.
+ Falls back to current calendar month if no active subscription.
+ """
+ from smoothschedule.billing.models import Subscription
+
+ try:
+ subscription = Subscription.objects.get(business=business)
+ if subscription.is_active and subscription.current_period_start:
+ return (
+ subscription.current_period_start.year,
+ subscription.current_period_start.month,
+ )
+ except Subscription.DoesNotExist:
+ pass
+
+ # Fallback to current calendar month
+ now = timezone.now()
+ return (now.year, now.month)
+
+ @classmethod
+ def get_current_quota_limit(cls, business: Tenant) -> int:
+ """
+ Get the current quota limit for a tenant.
+
+ Returns 0 for unlimited, or the integer limit.
+ """
+ limit = EntitlementService.get_limit(business, cls.FEATURE_CODE)
+ # None or 0 means unlimited
+ return limit if limit is not None else 0
+
+ @classmethod
+ def get_or_create_monthly_usage(
+ cls, business: Tenant, year: int | None = None, month: int | None = None
+ ) -> MonthlyQuotaUsage:
+ """
+ Get or create the monthly usage record for a tenant.
+
+ If year/month not provided, uses current billing period (based on subscription).
+ """
+ if year is None or month is None:
+ billing_year, billing_month = cls.get_current_billing_period(business)
+ if year is None:
+ year = billing_year
+ if month is None:
+ month = billing_month
+
+ quota_limit = cls.get_current_quota_limit(business)
+
+ usage, created = MonthlyQuotaUsage.objects.get_or_create(
+ business=business,
+ year=year,
+ month=month,
+ defaults={"quota_limit": quota_limit},
+ )
+
+ # Update quota limit if it changed (e.g., plan upgrade)
+ if not created and usage.quota_limit != quota_limit:
+ usage.quota_limit = quota_limit
+ usage.save(update_fields=["quota_limit", "updated_at"])
+
+ return usage
+
+ @classmethod
+ def get_quota_status(
+ cls,
+ business: Tenant,
+ user: User | None = None,
+ year: int | None = None,
+ month: int | None = None,
+ ) -> QuotaStatus:
+ """
+ Get the current quota status for a tenant.
+
+ If user is provided, also checks if they've dismissed the banner.
+ If year/month not provided, uses current billing period.
+ """
+ if year is None or month is None:
+ billing_year, billing_month = cls.get_current_billing_period(business)
+ if year is None:
+ year = billing_year
+ if month is None:
+ month = billing_month
+
+ usage = cls.get_or_create_monthly_usage(business, year, month)
+
+ # Check if user has dismissed the banner
+ show_banner = False
+ if usage.is_at_warning_threshold and user is not None:
+ dismissed = QuotaBannerDismissal.objects.filter(
+ user=user,
+ business=business,
+ year=year,
+ month=month,
+ ).exists()
+ show_banner = not dismissed
+ elif usage.is_at_warning_threshold:
+ show_banner = True
+
+ # Calculate flow execution charges (0.5 cents = $0.005 per execution)
+ flow_execution_amount_cents = int(
+ usage.flow_execution_count * cls.FLOW_EXECUTION_PRICE_CENTS
+ )
+
+ return QuotaStatus(
+ year=usage.year,
+ month=usage.month,
+ appointment_count=usage.appointment_count,
+ quota_limit=usage.quota_limit,
+ is_unlimited=usage.is_unlimited,
+ usage_percentage=usage.usage_percentage,
+ remaining_appointments=usage.remaining_appointments,
+ is_at_warning_threshold=usage.is_at_warning_threshold,
+ is_over_quota=usage.is_over_quota,
+ overage_count=usage.overage_count,
+ overage_amount_cents=usage.overage_amount_cents,
+ warning_email_sent=usage.warning_email_sent,
+ show_warning_banner=show_banner,
+ flow_execution_count=usage.flow_execution_count,
+ flow_execution_amount_cents=flow_execution_amount_cents,
+ )
+
+ @classmethod
+ @transaction.atomic
+ def increment_appointment_count(
+ cls,
+ business: Tenant,
+ count: int = 1,
+ event_start_time: datetime | None = None,
+ ) -> QuotaStatus:
+ """
+ Increment the appointment count for a tenant.
+
+ This is called when a new event is scheduled. Counts against the
+ current billing period, not the event's date.
+
+ Returns the updated quota status and handles:
+ - Incrementing the count
+ - Updating overage count if over quota
+ - Triggering warning email if at threshold
+
+ Args:
+ business: The tenant
+ count: Number of appointments to add (default 1)
+ event_start_time: Unused - kept for backward compatibility
+
+ Returns:
+ Updated QuotaStatus
+ """
+ # Always use billing period, not event date
+ year, month = cls.get_current_billing_period(business)
+
+ usage = cls.get_or_create_monthly_usage(business, year, month)
+
+ # Increment count
+ usage.appointment_count = F("appointment_count") + count
+ usage.save(update_fields=["appointment_count", "updated_at"])
+ usage.refresh_from_db()
+
+ # Update overage count if over quota
+ if not usage.is_unlimited and usage.appointment_count > usage.quota_limit:
+ overage = usage.appointment_count - usage.quota_limit
+ if overage > usage.overage_count:
+ usage.overage_count = overage
+ usage.save(update_fields=["overage_count", "updated_at"])
+
+ # Check if we need to send warning email
+ if (
+ usage.is_at_warning_threshold
+ and not usage.warning_email_sent
+ and not usage.is_unlimited
+ ):
+ cls._send_warning_email(business, usage)
+
+ return cls.get_quota_status(business, year=year, month=month)
+
+ @classmethod
+ @transaction.atomic
+ def decrement_appointment_count(
+ cls,
+ business: Tenant,
+ count: int = 1,
+ event_start_time: datetime | None = None,
+ ) -> QuotaStatus:
+ """
+ Decrement the appointment count when an event is canceled/deleted.
+
+ Note: Overage count is NOT decremented. Once an overage occurs,
+ it's recorded for billing purposes even if appointments are later canceled.
+
+ Args:
+ business: The tenant
+ count: Number of appointments to remove (default 1)
+ event_start_time: Unused - kept for backward compatibility
+
+ Returns:
+ Updated QuotaStatus
+ """
+ # Always use billing period
+ year, month = cls.get_current_billing_period(business)
+
+ try:
+ usage = MonthlyQuotaUsage.objects.get(
+ business=business, year=year, month=month
+ )
+ # Decrement but don't go below 0
+ new_count = max(0, usage.appointment_count - count)
+ usage.appointment_count = new_count
+ usage.save(update_fields=["appointment_count", "updated_at"])
+ except MonthlyQuotaUsage.DoesNotExist:
+ # No usage record for this month, nothing to decrement
+ pass
+
+ return cls.get_quota_status(business, year=year, month=month)
+
+ @classmethod
+ def dismiss_warning_banner(
+ cls,
+ user: User,
+ business: Tenant,
+ year: int | None = None,
+ month: int | None = None,
+ ) -> None:
+ """
+ Record that a user has dismissed the warning banner.
+
+ The banner won't show again for this user/business/month combo.
+ If year/month not provided, uses current billing period.
+ """
+ if year is None or month is None:
+ billing_year, billing_month = cls.get_current_billing_period(business)
+ if year is None:
+ year = billing_year
+ if month is None:
+ month = billing_month
+
+ QuotaBannerDismissal.objects.get_or_create(
+ user=user,
+ business=business,
+ year=year,
+ month=month,
+ )
+
+ @classmethod
+ @transaction.atomic
+ def increment_flow_execution_count(
+ cls,
+ business: Tenant,
+ count: int = 1,
+ is_test: bool = False,
+ ) -> MonthlyQuotaUsage:
+ """
+ Increment the flow execution count for a tenant.
+
+ This is called when an automation flow executes. Testing through
+ the UI should NOT count against the quota.
+
+ Args:
+ business: The tenant
+ count: Number of executions to add (default 1)
+ is_test: If True, this is a UI test and should not be counted
+
+ Returns:
+ Updated MonthlyQuotaUsage
+ """
+ if is_test:
+ logger.debug(f"Skipping flow execution count for test run: {business.name}")
+ return cls.get_or_create_monthly_usage(business)
+
+ year, month = cls.get_current_billing_period(business)
+ usage = cls.get_or_create_monthly_usage(business, year, month)
+
+ # Increment count
+ usage.flow_execution_count = F("flow_execution_count") + count
+ usage.save(update_fields=["flow_execution_count", "updated_at"])
+ usage.refresh_from_db()
+
+ logger.info(
+ f"Incremented flow execution count for {business.name}: "
+ f"{usage.flow_execution_count} executions in {year}/{month:02d}"
+ )
+
+ return usage
+
+ @classmethod
+ def _send_warning_email(cls, business: Tenant, usage: MonthlyQuotaUsage) -> None:
+ """
+ Send quota warning email to business owner(s).
+
+ This is called when usage reaches 90% of quota.
+ """
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ try:
+ # Get business owners/admins to email
+ owners = business.users.filter(role__in=["owner", "manager"])
+
+ if not owners.exists():
+ logger.warning(
+ f"No owners/managers to send quota warning email for {business.name}"
+ )
+ return
+
+ # Calculate stats for email
+ remaining = usage.remaining_appointments or 0
+ percentage = round(usage.usage_percentage)
+
+ for owner in owners:
+ try:
+ EmailService.send_quota_warning_email(
+ to_email=owner.email,
+ to_name=owner.name or owner.email,
+ business_name=business.name,
+ current_count=usage.appointment_count,
+ quota_limit=usage.quota_limit,
+ remaining=remaining,
+ percentage=percentage,
+ overage_price_cents=cls.OVERAGE_PRICE_CENTS,
+ )
+ except Exception as e:
+ logger.error(
+ f"Failed to send quota warning email to {owner.email}: {e}"
+ )
+
+ # Mark warning email as sent
+ usage.warning_email_sent = True
+ usage.warning_email_sent_at = timezone.now()
+ usage.save(update_fields=["warning_email_sent", "warning_email_sent_at", "updated_at"])
+
+ logger.info(
+ f"Sent quota warning email for {business.name} "
+ f"({usage.appointment_count}/{usage.quota_limit})"
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send quota warning emails for {business.name}: {e}")
+
+ @classmethod
+ def recalculate_monthly_usage(
+ cls,
+ business: Tenant,
+ year: int | None = None,
+ month: int | None = None,
+ ) -> QuotaStatus:
+ """
+ Recalculate the monthly appointment count from the Event table.
+
+ This is useful for fixing drift or after bulk operations.
+ """
+ from django.db.models import Count
+
+ now = timezone.now()
+ if year is None:
+ year = now.year
+ if month is None:
+ month = now.month
+
+ # Import here to avoid circular imports
+ from django.db import connection
+
+ # Count events for this month in the tenant's schema
+ # Events are in tenant schema, so we need to use the tenant connection
+ with connection.cursor() as cursor:
+ cursor.execute(f'SET search_path TO "{business.schema_name}"')
+
+ from smoothschedule.scheduling.schedule.models import Event
+
+ # Count scheduled events for this month
+ # Only count events that are not canceled
+ start_of_month = timezone.make_aware(
+ datetime(year, month, 1), timezone.get_current_timezone()
+ )
+ if month == 12:
+ end_of_month = timezone.make_aware(
+ datetime(year + 1, 1, 1), timezone.get_current_timezone()
+ )
+ else:
+ end_of_month = timezone.make_aware(
+ datetime(year, month + 1, 1), timezone.get_current_timezone()
+ )
+
+ count = Event.objects.filter(
+ start_time__gte=start_of_month,
+ start_time__lt=end_of_month,
+ ).exclude(
+ status__in=[Event.Status.CANCELED]
+ ).count()
+
+ # Update the usage record
+ usage = cls.get_or_create_monthly_usage(business, year, month)
+ usage.appointment_count = count
+
+ # Recalculate overage
+ if not usage.is_unlimited and count > usage.quota_limit:
+ usage.overage_count = count - usage.quota_limit
+ else:
+ usage.overage_count = 0
+
+ usage.save(update_fields=["appointment_count", "overage_count", "updated_at"])
+
+ return cls.get_quota_status(business, year=year, month=month)
+
+ # =========================================================================
+ # API Request Tracking (Daily)
+ # =========================================================================
+
+ API_FEATURE_CODE = "max_api_requests_per_day"
+
+ @classmethod
+ def get_api_request_quota_limit(cls, business: Tenant) -> int:
+ """
+ Get the daily API request quota limit for a tenant.
+
+ Returns 0 for unlimited, or the integer limit.
+ """
+ limit = EntitlementService.get_limit(business, cls.API_FEATURE_CODE)
+ # None or 0 means unlimited
+ return limit if limit is not None else 0
+
+ @classmethod
+ def get_or_create_daily_api_usage(
+ cls, business: Tenant, date: datetime | None = None
+ ) -> DailyApiUsage:
+ """
+ Get or create the daily API usage record for a tenant.
+
+ If date not provided, uses current UTC date.
+ """
+ if date is None:
+ date = timezone.now().date()
+ elif hasattr(date, 'date'):
+ date = date.date()
+
+ quota_limit = cls.get_api_request_quota_limit(business)
+
+ usage, created = DailyApiUsage.objects.get_or_create(
+ business=business,
+ date=date,
+ defaults={"quota_limit": quota_limit},
+ )
+
+ # Update quota limit if it changed (e.g., plan upgrade)
+ if not created and usage.quota_limit != quota_limit:
+ usage.quota_limit = quota_limit
+ usage.save(update_fields=["quota_limit", "updated_at"])
+
+ return usage
+
+ @classmethod
+ @transaction.atomic
+ def increment_api_request_count(
+ cls,
+ business: Tenant,
+ count: int = 1,
+ ) -> tuple[DailyApiUsage, bool]:
+ """
+ Increment the API request count for a tenant.
+
+ This is called for each API request from the public API.
+ Does NOT block requests if over quota - just tracks usage.
+
+ Args:
+ business: The tenant
+ count: Number of requests to add (default 1)
+
+ Returns:
+ Tuple of (DailyApiUsage, is_over_quota)
+ """
+ usage = cls.get_or_create_daily_api_usage(business)
+
+ # Increment count
+ usage.request_count = F("request_count") + count
+ usage.save(update_fields=["request_count", "updated_at"])
+ usage.refresh_from_db()
+
+ return usage, usage.is_over_quota
+
+ @classmethod
+ def check_api_quota(cls, business: Tenant) -> tuple[bool, int | None]:
+ """
+ Check if a tenant is within their API quota.
+
+ Args:
+ business: The tenant
+
+ Returns:
+ Tuple of (is_allowed, remaining_requests)
+ - is_allowed: True if request should be allowed
+ - remaining_requests: Number of requests remaining (None if unlimited)
+ """
+ usage = cls.get_or_create_daily_api_usage(business)
+
+ if usage.is_unlimited:
+ return True, None
+
+ remaining = usage.remaining_requests
+ is_allowed = remaining > 0
+
+ return is_allowed, remaining
+
+ @classmethod
+ def get_api_usage_status(cls, business: Tenant) -> dict:
+ """
+ Get the current API usage status for a tenant.
+
+ Returns:
+ Dictionary with usage info
+ """
+ usage = cls.get_or_create_daily_api_usage(business)
+
+ return {
+ "date": usage.date.isoformat(),
+ "request_count": usage.request_count,
+ "quota_limit": usage.quota_limit,
+ "is_unlimited": usage.is_unlimited,
+ "is_over_quota": usage.is_over_quota,
+ "remaining_requests": usage.remaining_requests,
+ "usage_percentage": usage.usage_percentage,
+ }
diff --git a/smoothschedule/smoothschedule/billing/services/storage.py b/smoothschedule/smoothschedule/billing/services/storage.py
new file mode 100644
index 00000000..74ded839
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/services/storage.py
@@ -0,0 +1,335 @@
+"""
+StorageService - Measures and tracks database storage usage per tenant.
+
+This service handles:
+- Measuring PostgreSQL schema sizes for each tenant
+- Tracking storage usage against quotas
+- Sending warning emails at 90% threshold
+- Calculating overage charges ($0.50 per GB over quota)
+
+Storage is measured periodically by a Celery task and cached in the
+StorageUsage model. This avoids expensive real-time queries.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, NamedTuple
+
+from django.db import connection
+from django.utils import timezone
+
+from smoothschedule.billing.models import StorageUsage
+from smoothschedule.billing.services.entitlements import EntitlementService
+
+if TYPE_CHECKING:
+ from smoothschedule.identity.core.models import Tenant
+
+logger = logging.getLogger(__name__)
+
+
+class StorageStatus(NamedTuple):
+ """Current storage status for a tenant."""
+ year: int
+ month: int
+ current_size_bytes: int
+ current_size_mb: float
+ current_size_gb: float
+ peak_size_bytes: int
+ peak_size_mb: float
+ quota_limit_mb: int
+ quota_limit_gb: float
+ is_unlimited: bool
+ usage_percentage: float
+ remaining_mb: float | None
+ is_at_warning_threshold: bool
+ is_over_quota: bool
+ overage_mb: float
+ overage_amount_cents: int
+ warning_email_sent: bool
+ last_measured_at: str | None
+ table_sizes: dict
+
+
+class StorageService:
+ """Service for measuring and tracking database storage usage."""
+
+ FEATURE_CODE = "max_storage_mb"
+ WARNING_THRESHOLD_PERCENT = 90
+ OVERAGE_PRICE_CENTS_PER_GB = 50 # $0.50 per GB
+
+ @classmethod
+ def get_storage_quota_limit(cls, business: Tenant) -> int:
+ """
+ Get the storage quota limit in MB for a tenant.
+
+ Returns 0 for unlimited, or the integer limit in MB.
+ """
+ limit = EntitlementService.get_limit(business, cls.FEATURE_CODE)
+ # None or 0 means unlimited
+ return limit if limit is not None else 0
+
+ @classmethod
+ def get_current_billing_period(cls, business: Tenant) -> tuple[int, int]:
+ """
+ Get the current billing period (year, month) for a tenant.
+
+ Uses the same logic as QuotaService for consistency.
+ """
+ from smoothschedule.billing.services.quota import QuotaService
+ return QuotaService.get_current_billing_period(business)
+
+ @classmethod
+ def get_or_create_storage_usage(
+ cls, business: Tenant, year: int | None = None, month: int | None = None
+ ) -> StorageUsage:
+ """
+ Get or create the storage usage record for a tenant.
+
+ If year/month not provided, uses current billing period.
+ """
+ if year is None or month is None:
+ billing_year, billing_month = cls.get_current_billing_period(business)
+ if year is None:
+ year = billing_year
+ if month is None:
+ month = billing_month
+
+ quota_limit_mb = cls.get_storage_quota_limit(business)
+
+ usage, created = StorageUsage.objects.get_or_create(
+ business=business,
+ year=year,
+ month=month,
+ defaults={"quota_limit_mb": quota_limit_mb},
+ )
+
+ # Update quota limit if it changed (e.g., plan upgrade)
+ if not created and usage.quota_limit_mb != quota_limit_mb:
+ usage.quota_limit_mb = quota_limit_mb
+ usage.save(update_fields=["quota_limit_mb", "updated_at"])
+
+ return usage
+
+ @classmethod
+ def measure_schema_size(cls, schema_name: str) -> tuple[int, dict]:
+ """
+ Measure the size of a PostgreSQL schema.
+
+ Args:
+ schema_name: The schema name to measure
+
+ Returns:
+ Tuple of (total_size_bytes, table_sizes_dict)
+ """
+ table_sizes = {}
+ total_size = 0
+
+ try:
+ with connection.cursor() as cursor:
+ # Get size of each table in the schema
+ cursor.execute("""
+ SELECT
+ tablename,
+ pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename)) as size
+ FROM pg_tables
+ WHERE schemaname = %s
+ ORDER BY size DESC
+ """, [schema_name])
+
+ for row in cursor.fetchall():
+ table_name, size = row
+ table_sizes[table_name] = size
+ total_size += size
+
+ # Also include indexes not accounted for in table sizes
+ cursor.execute("""
+ SELECT
+ COALESCE(sum(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(indexname))), 0)
+ FROM pg_indexes
+ WHERE schemaname = %s
+ """, [schema_name])
+
+ # Note: pg_total_relation_size already includes indexes,
+ # so we don't need to add this separately
+
+ except Exception as e:
+ logger.error(f"Failed to measure schema size for {schema_name}: {e}")
+
+ return total_size, table_sizes
+
+ @classmethod
+ def measure_tenant_storage(cls, business: Tenant) -> StorageUsage:
+ """
+ Measure and update storage for a single tenant.
+
+ Args:
+ business: The tenant to measure
+
+ Returns:
+ Updated StorageUsage record
+ """
+ # Measure the schema
+ total_size, table_sizes = cls.measure_schema_size(business.schema_name)
+
+ # Get or create usage record
+ usage = cls.get_or_create_storage_usage(business)
+
+ # Update the measurement
+ usage.update_measurement(total_size, table_sizes)
+
+ # Check if we need to send warning email
+ if (
+ usage.is_at_warning_threshold
+ and not usage.warning_email_sent
+ and not usage.is_unlimited
+ ):
+ cls._send_warning_email(business, usage)
+
+ logger.info(
+ f"Measured storage for {business.name}: "
+ f"{usage.current_size_mb:.1f}MB / {usage.quota_limit_mb or 'unlimited'}MB"
+ )
+
+ return usage
+
+ @classmethod
+ def measure_all_tenants(cls) -> list[StorageUsage]:
+ """
+ Measure storage for all active tenants.
+
+ Called by Celery task periodically.
+
+ Returns:
+ List of updated StorageUsage records
+ """
+ from smoothschedule.identity.core.models import Tenant
+
+ results = []
+ tenants = Tenant.objects.exclude(schema_name='public').filter(is_active=True)
+
+ for tenant in tenants:
+ try:
+ usage = cls.measure_tenant_storage(tenant)
+ results.append(usage)
+ except Exception as e:
+ logger.error(f"Failed to measure storage for {tenant.name}: {e}")
+
+ logger.info(f"Measured storage for {len(results)} tenants")
+ return results
+
+ @classmethod
+ def get_storage_status(
+ cls,
+ business: Tenant,
+ year: int | None = None,
+ month: int | None = None,
+ ) -> StorageStatus:
+ """
+ Get the current storage status for a tenant.
+
+ If year/month not provided, uses current billing period.
+ """
+ if year is None or month is None:
+ billing_year, billing_month = cls.get_current_billing_period(business)
+ if year is None:
+ year = billing_year
+ if month is None:
+ month = billing_month
+
+ usage = cls.get_or_create_storage_usage(business, year, month)
+
+ return StorageStatus(
+ year=usage.year,
+ month=usage.month,
+ current_size_bytes=usage.current_size_bytes,
+ current_size_mb=usage.current_size_mb,
+ current_size_gb=usage.current_size_gb,
+ peak_size_bytes=usage.peak_size_bytes,
+ peak_size_mb=usage.peak_size_mb,
+ quota_limit_mb=usage.quota_limit_mb,
+ quota_limit_gb=usage.quota_limit_gb,
+ is_unlimited=usage.is_unlimited,
+ usage_percentage=usage.usage_percentage,
+ remaining_mb=usage.remaining_mb,
+ is_at_warning_threshold=usage.is_at_warning_threshold,
+ is_over_quota=usage.is_over_quota,
+ overage_mb=usage.overage_mb,
+ overage_amount_cents=usage.overage_amount_cents,
+ warning_email_sent=usage.warning_email_sent,
+ last_measured_at=usage.last_measured_at.isoformat() if usage.last_measured_at else None,
+ table_sizes=usage.table_sizes,
+ )
+
+ @classmethod
+ def _send_warning_email(cls, business: Tenant, usage: StorageUsage) -> None:
+ """
+ Send storage warning email to business owner(s).
+
+ This is called when usage reaches 90% of quota.
+ """
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ try:
+ # Get business owners/admins to email
+ owners = business.users.filter(role__in=["owner", "manager"])
+
+ if not owners.exists():
+ logger.warning(
+ f"No owners/managers to send storage warning email for {business.name}"
+ )
+ return
+
+ # Calculate stats for email
+ remaining = usage.remaining_mb or 0
+ percentage = round(usage.usage_percentage)
+
+ for owner in owners:
+ try:
+ EmailService.send_storage_warning_email(
+ to_email=owner.email,
+ to_name=owner.name or owner.email,
+ business_name=business.name,
+ current_mb=round(usage.current_size_mb, 1),
+ quota_limit_mb=usage.quota_limit_mb,
+ remaining_mb=round(remaining, 1),
+ percentage=percentage,
+ overage_price_cents_per_gb=cls.OVERAGE_PRICE_CENTS_PER_GB,
+ )
+ except Exception as e:
+ logger.error(
+ f"Failed to send storage warning email to {owner.email}: {e}"
+ )
+
+ # Mark warning email as sent
+ usage.warning_email_sent = True
+ usage.warning_email_sent_at = timezone.now()
+ usage.save(update_fields=["warning_email_sent", "warning_email_sent_at", "updated_at"])
+
+ logger.info(
+ f"Sent storage warning email for {business.name} "
+ f"({usage.current_size_mb:.1f}MB/{usage.quota_limit_mb}MB)"
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to send storage warning emails for {business.name}: {e}")
+
+ @classmethod
+ def format_size(cls, size_bytes: int) -> str:
+ """
+ Format a size in bytes to a human-readable string.
+
+ Args:
+ size_bytes: Size in bytes
+
+ Returns:
+ Formatted string like "1.5 GB" or "256 MB"
+ """
+ if size_bytes >= 1024 * 1024 * 1024: # >= 1 GB
+ return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
+ elif size_bytes >= 1024 * 1024: # >= 1 MB
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ elif size_bytes >= 1024: # >= 1 KB
+ return f"{size_bytes / 1024:.1f} KB"
+ else:
+ return f"{size_bytes} bytes"
diff --git a/smoothschedule/smoothschedule/billing/tasks.py b/smoothschedule/smoothschedule/billing/tasks.py
index c1ccc86f..3cdbaa42 100644
--- a/smoothschedule/smoothschedule/billing/tasks.py
+++ b/smoothschedule/smoothschedule/billing/tasks.py
@@ -2,7 +2,7 @@
Celery tasks for billing operations.
These tasks run periodically to manage subscription-related operations like
-grace period tracking for custom tiers.
+grace period tracking for custom tiers and storage usage measurement.
"""
from datetime import timedelta
@@ -13,6 +13,89 @@ import logging
logger = logging.getLogger(__name__)
+@shared_task
+def measure_all_tenant_storage():
+ """
+ Measure database storage for all active tenants.
+
+ This task should run hourly to:
+ 1. Query PostgreSQL schema sizes for each tenant
+ 2. Update StorageUsage records with current measurements
+ 3. Track peak usage for billing purposes
+ 4. Send warning emails at 90% threshold
+
+ Returns:
+ dict: Summary of measurements taken
+ """
+ from smoothschedule.billing.services.storage import StorageService
+
+ results = {
+ 'tenants_measured': 0,
+ 'warnings_sent': 0,
+ 'errors': [],
+ }
+
+ try:
+ usages = StorageService.measure_all_tenants()
+ results['tenants_measured'] = len(usages)
+
+ # Count warnings sent
+ for usage in usages:
+ if usage.warning_email_sent and usage.warning_email_sent_at:
+ # Check if warning was sent in this run (within last minute)
+ if (timezone.now() - usage.warning_email_sent_at).seconds < 60:
+ results['warnings_sent'] += 1
+
+ logger.info(
+ f"Storage measurement complete: {results['tenants_measured']} tenants measured, "
+ f"{results['warnings_sent']} warnings sent"
+ )
+
+ except Exception as e:
+ error_msg = f"Error measuring storage: {str(e)}"
+ logger.error(error_msg, exc_info=True)
+ results['errors'].append(error_msg)
+
+ return results
+
+
+@shared_task
+def measure_tenant_storage(tenant_id: int):
+ """
+ Measure database storage for a single tenant.
+
+ This can be triggered on-demand for real-time usage checks.
+
+ Args:
+ tenant_id: The ID of the tenant to measure
+
+ Returns:
+ dict: Storage measurement results
+ """
+ from smoothschedule.identity.core.models import Tenant
+ from smoothschedule.billing.services.storage import StorageService
+
+ try:
+ tenant = Tenant.objects.get(id=tenant_id)
+ usage = StorageService.measure_tenant_storage(tenant)
+
+ return {
+ 'tenant_id': tenant_id,
+ 'tenant_name': tenant.name,
+ 'current_size_mb': usage.current_size_mb,
+ 'quota_limit_mb': usage.quota_limit_mb,
+ 'usage_percentage': usage.usage_percentage,
+ 'is_over_quota': usage.is_over_quota,
+ }
+
+ 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 measuring storage for tenant {tenant_id}: {e}")
+ return {'error': str(e)}
+
+
@shared_task
def check_subscription_grace_periods():
"""
diff --git a/smoothschedule/smoothschedule/billing/tests/test_quota.py b/smoothschedule/smoothschedule/billing/tests/test_quota.py
new file mode 100644
index 00000000..69224ec2
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/tests/test_quota.py
@@ -0,0 +1,582 @@
+"""
+Tests for QuotaService - Quota Tracking and Enforcement.
+
+Quota Types:
+- Daily reset: API calls (max_api_requests_per_day)
+- Monthly reset (billing cycle): Appointments, Emails, SMS, Automation Runs
+- No reset (permanent max): max_users, max_resources, max_locations, etc.
+
+These tests verify:
+1. Billing period determination (based on subscription start date)
+2. Quota status calculations
+3. Warning threshold logic (90%)
+4. Banner dismissal tracking
+5. Daily vs monthly reset behavior
+"""
+from datetime import date, datetime, timedelta, timezone as dt_timezone
+from decimal import Decimal
+from unittest.mock import Mock, patch, MagicMock, PropertyMock
+
+import pytest
+from django.utils import timezone
+
+from smoothschedule.billing.models import (
+ DailyApiUsage,
+ MonthlyQuotaUsage,
+ QuotaBannerDismissal,
+ Subscription,
+)
+from smoothschedule.billing.services.quota import QuotaService, QuotaStatus
+
+
+class TestBillingPeriodDetermination:
+ """Tests for billing period determination."""
+
+ def test_get_billing_period_from_active_subscription(self):
+ """Should return year/month from subscription's current_period_start."""
+ mock_tenant = Mock()
+ mock_subscription = Mock()
+ mock_subscription.is_active = True
+ mock_subscription.current_period_start = datetime(2024, 6, 15, tzinfo=dt_timezone.utc)
+
+ with patch.object(
+ Subscription.objects, 'get', return_value=mock_subscription
+ ):
+ year, month = QuotaService.get_current_billing_period(mock_tenant)
+
+ assert year == 2024
+ assert month == 6
+
+ def test_get_billing_period_falls_back_to_calendar_month(self):
+ """Should return current calendar month if no subscription."""
+ mock_tenant = Mock()
+
+ with patch.object(
+ Subscription.objects, 'get', side_effect=Subscription.DoesNotExist
+ ):
+ with patch.object(timezone, 'now') as mock_now:
+ mock_now.return_value = datetime(2024, 12, 25, tzinfo=dt_timezone.utc)
+ year, month = QuotaService.get_current_billing_period(mock_tenant)
+
+ assert year == 2024
+ assert month == 12
+
+ def test_get_billing_period_inactive_subscription_uses_calendar(self):
+ """Should fall back to calendar month if subscription is inactive."""
+ mock_tenant = Mock()
+ mock_subscription = Mock()
+ mock_subscription.is_active = False
+
+ with patch.object(
+ Subscription.objects, 'get', return_value=mock_subscription
+ ):
+ with patch.object(timezone, 'now') as mock_now:
+ mock_now.return_value = datetime(2024, 11, 10, tzinfo=dt_timezone.utc)
+ year, month = QuotaService.get_current_billing_period(mock_tenant)
+
+ assert year == 2024
+ assert month == 11
+
+
+class TestQuotaStatusCalculation:
+ """Tests for QuotaStatus calculation without DB access."""
+
+ def test_quota_status_includes_all_required_fields(self):
+ """QuotaStatus should include all required fields."""
+ fields = QuotaStatus._fields
+
+ expected_fields = {
+ 'year', 'month', 'appointment_count', 'quota_limit',
+ 'is_unlimited', 'usage_percentage', 'remaining_appointments',
+ 'is_at_warning_threshold', 'is_over_quota', 'overage_count',
+ 'overage_amount_cents', 'warning_email_sent', 'show_warning_banner',
+ 'flow_execution_count', 'flow_execution_amount_cents',
+ }
+ assert set(fields) == expected_fields
+
+ def test_flow_execution_pricing_calculation(self):
+ """Should calculate $0.005 per flow execution correctly."""
+ # 1000 executions * 0.5 cents = 500 cents = $5.00
+ flow_count = 1000
+ expected_cents = int(flow_count * QuotaService.FLOW_EXECUTION_PRICE_CENTS)
+ assert expected_cents == 500
+
+ def test_overage_pricing_calculation(self):
+ """Should calculate $0.10 per overage appointment."""
+ # 10 overage * 10 cents = 100 cents = $1.00
+ overage_count = 10
+ expected_cents = overage_count * QuotaService.OVERAGE_PRICE_CENTS
+ assert expected_cents == 100
+
+
+class TestMonthlyQuotaUsageModel:
+ """Tests for MonthlyQuotaUsage model properties."""
+
+ def test_is_unlimited_when_quota_zero(self):
+ """quota_limit=0 means unlimited."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=100,
+ quota_limit=0
+ )
+ assert usage.is_unlimited is True
+
+ def test_is_not_unlimited_when_quota_set(self):
+ """quota_limit>0 means limited."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=50,
+ quota_limit=100
+ )
+ assert usage.is_unlimited is False
+
+ def test_usage_percentage_calculation(self):
+ """Should calculate percentage correctly."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=75,
+ quota_limit=100
+ )
+ assert usage.usage_percentage == 75.0
+
+ def test_usage_percentage_zero_when_unlimited(self):
+ """Should return 0% for unlimited plans."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=1000,
+ quota_limit=0
+ )
+ assert usage.usage_percentage == 0.0
+
+ def test_is_at_warning_threshold_at_90_percent(self):
+ """Should trigger warning at 90%."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=90,
+ quota_limit=100
+ )
+ assert usage.is_at_warning_threshold is True
+
+ def test_is_not_at_warning_threshold_below_90(self):
+ """Should not trigger warning below 90%."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=89,
+ quota_limit=100
+ )
+ assert usage.is_at_warning_threshold is False
+
+ def test_is_over_quota(self):
+ """Should detect when over quota."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=101,
+ quota_limit=100
+ )
+ assert usage.is_over_quota is True
+
+ def test_is_not_over_quota(self):
+ """Should detect when under quota."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=99,
+ quota_limit=100
+ )
+ assert usage.is_over_quota is False
+
+ def test_remaining_appointments_calculation(self):
+ """Should calculate remaining correctly."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=75,
+ quota_limit=100
+ )
+ assert usage.remaining_appointments == 25
+
+ def test_remaining_appointments_none_when_unlimited(self):
+ """Should return None for unlimited plans."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=1000,
+ quota_limit=0
+ )
+ assert usage.remaining_appointments is None
+
+ def test_overage_amount_cents_calculation(self):
+ """Should calculate overage charges."""
+ usage = MonthlyQuotaUsage(
+ year=2024, month=6,
+ appointment_count=110,
+ quota_limit=100,
+ overage_count=10
+ )
+ # 10 overage * 10 cents = 100 cents
+ assert usage.overage_amount_cents == 100
+
+
+class TestDailyApiUsageModel:
+ """Tests for DailyApiUsage model properties."""
+
+ def test_is_unlimited_when_quota_zero(self):
+ """quota_limit=0 means unlimited."""
+ usage = DailyApiUsage(
+ date=date(2024, 6, 15),
+ request_count=1000,
+ quota_limit=0
+ )
+ assert usage.is_unlimited is True
+
+ def test_is_over_quota(self):
+ """Should detect when at or over quota."""
+ usage = DailyApiUsage(
+ date=date(2024, 6, 15),
+ request_count=1000,
+ quota_limit=1000
+ )
+ assert usage.is_over_quota is True
+
+ def test_remaining_requests(self):
+ """Should calculate remaining requests."""
+ usage = DailyApiUsage(
+ date=date(2024, 6, 15),
+ request_count=750,
+ quota_limit=1000
+ )
+ assert usage.remaining_requests == 250
+
+ def test_usage_percentage(self):
+ """Should calculate usage percentage."""
+ usage = DailyApiUsage(
+ date=date(2024, 6, 15),
+ request_count=500,
+ quota_limit=1000
+ )
+ assert usage.usage_percentage == 50.0
+
+
+class TestQuotaLimitRetrieval:
+ """Tests for quota limit retrieval from entitlements."""
+
+ def test_get_current_quota_limit_returns_integer(self):
+ """Should return integer limit from entitlements."""
+ mock_tenant = Mock()
+
+ with patch(
+ 'smoothschedule.billing.services.quota.EntitlementService.get_limit',
+ return_value=100
+ ):
+ limit = QuotaService.get_current_quota_limit(mock_tenant)
+ assert limit == 100
+
+ def test_get_current_quota_limit_returns_zero_for_none(self):
+ """Should return 0 (unlimited) when entitlement returns None."""
+ mock_tenant = Mock()
+
+ with patch(
+ 'smoothschedule.billing.services.quota.EntitlementService.get_limit',
+ return_value=None
+ ):
+ limit = QuotaService.get_current_quota_limit(mock_tenant)
+ assert limit == 0
+
+ def test_get_api_request_quota_limit_returns_integer(self):
+ """Should return integer limit for API requests."""
+ mock_tenant = Mock()
+
+ with patch(
+ 'smoothschedule.billing.services.quota.EntitlementService.get_limit',
+ return_value=1000
+ ):
+ limit = QuotaService.get_api_request_quota_limit(mock_tenant)
+ assert limit == 1000
+
+
+class TestBannerDismissalLogic:
+ """Tests for quota warning banner dismissal."""
+
+ def test_show_banner_when_at_threshold_and_not_dismissed(self):
+ """Should show banner when at threshold and not dismissed."""
+ mock_tenant = Mock()
+ mock_user = Mock()
+ mock_usage = Mock()
+ mock_usage.year = 2024
+ mock_usage.month = 6
+ mock_usage.appointment_count = 95
+ mock_usage.quota_limit = 100
+ mock_usage.is_unlimited = False
+ mock_usage.usage_percentage = 95.0
+ mock_usage.remaining_appointments = 5
+ mock_usage.is_at_warning_threshold = True
+ mock_usage.is_over_quota = False
+ mock_usage.overage_count = 0
+ mock_usage.overage_amount_cents = 0
+ mock_usage.warning_email_sent = True
+ mock_usage.flow_execution_count = 0
+
+ with patch.object(
+ QuotaService, 'get_current_billing_period', return_value=(2024, 6)
+ ), patch.object(
+ QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
+ ), patch.object(
+ QuotaBannerDismissal.objects, 'filter'
+ ) as mock_filter:
+ mock_filter.return_value.exists.return_value = False # Not dismissed
+
+ status = QuotaService.get_quota_status(mock_tenant, user=mock_user)
+ assert status.show_warning_banner is True
+
+ def test_hide_banner_when_dismissed(self):
+ """Should hide banner when already dismissed."""
+ mock_tenant = Mock()
+ mock_user = Mock()
+ mock_usage = Mock()
+ mock_usage.year = 2024
+ mock_usage.month = 6
+ mock_usage.appointment_count = 95
+ mock_usage.quota_limit = 100
+ mock_usage.is_unlimited = False
+ mock_usage.usage_percentage = 95.0
+ mock_usage.remaining_appointments = 5
+ mock_usage.is_at_warning_threshold = True
+ mock_usage.is_over_quota = False
+ mock_usage.overage_count = 0
+ mock_usage.overage_amount_cents = 0
+ mock_usage.warning_email_sent = True
+ mock_usage.flow_execution_count = 0
+
+ with patch.object(
+ QuotaService, 'get_current_billing_period', return_value=(2024, 6)
+ ), patch.object(
+ QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
+ ), patch.object(
+ QuotaBannerDismissal.objects, 'filter'
+ ) as mock_filter:
+ mock_filter.return_value.exists.return_value = True # Dismissed
+
+ status = QuotaService.get_quota_status(mock_tenant, user=mock_user)
+ assert status.show_warning_banner is False
+
+ def test_hide_banner_when_below_threshold(self):
+ """Should hide banner when below warning threshold."""
+ mock_tenant = Mock()
+ mock_usage = Mock()
+ mock_usage.year = 2024
+ mock_usage.month = 6
+ mock_usage.appointment_count = 50
+ mock_usage.quota_limit = 100
+ mock_usage.is_unlimited = False
+ mock_usage.usage_percentage = 50.0
+ mock_usage.remaining_appointments = 50
+ mock_usage.is_at_warning_threshold = False # Below threshold
+ mock_usage.is_over_quota = False
+ mock_usage.overage_count = 0
+ mock_usage.overage_amount_cents = 0
+ mock_usage.warning_email_sent = False
+ mock_usage.flow_execution_count = 0
+
+ with patch.object(
+ QuotaService, 'get_current_billing_period', return_value=(2024, 6)
+ ), patch.object(
+ QuotaService, 'get_or_create_monthly_usage', return_value=mock_usage
+ ):
+ status = QuotaService.get_quota_status(mock_tenant)
+ assert status.show_warning_banner is False
+
+
+class TestQuotaApiRequestTracking:
+ """Tests for API request quota checking (non-DB tests)."""
+
+ def test_check_api_quota_allows_when_under_limit(self):
+ """Should allow requests when under quota."""
+ mock_tenant = Mock()
+ mock_usage = Mock()
+ mock_usage.is_unlimited = False
+ mock_usage.remaining_requests = 50
+
+ with patch.object(
+ QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
+ ):
+ is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
+ assert is_allowed is True
+ assert remaining == 50
+
+ def test_check_api_quota_blocks_when_at_limit(self):
+ """Should block when at quota limit."""
+ mock_tenant = Mock()
+ mock_usage = Mock()
+ mock_usage.is_unlimited = False
+ mock_usage.remaining_requests = 0
+
+ with patch.object(
+ QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
+ ):
+ is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
+ assert is_allowed is False
+ assert remaining == 0
+
+ def test_check_api_quota_always_allows_unlimited(self):
+ """Should always allow for unlimited plans."""
+ mock_tenant = Mock()
+ mock_usage = Mock()
+ mock_usage.is_unlimited = True
+
+ with patch.object(
+ QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
+ ):
+ is_allowed, remaining = QuotaService.check_api_quota(mock_tenant)
+ assert is_allowed is True
+ assert remaining is None
+
+ def test_get_api_usage_status_returns_dict(self):
+ """Should return dictionary with usage info."""
+ mock_tenant = Mock()
+ mock_usage = Mock()
+ mock_usage.date = date(2024, 6, 15)
+ mock_usage.request_count = 500
+ mock_usage.quota_limit = 1000
+ mock_usage.is_unlimited = False
+ mock_usage.is_over_quota = False
+ mock_usage.remaining_requests = 500
+ mock_usage.usage_percentage = 50.0
+
+ with patch.object(
+ QuotaService, 'get_or_create_daily_api_usage', return_value=mock_usage
+ ):
+ status = QuotaService.get_api_usage_status(mock_tenant)
+
+ assert status['date'] == '2024-06-15'
+ assert status['request_count'] == 500
+ assert status['quota_limit'] == 1000
+ assert status['is_unlimited'] is False
+ assert status['remaining_requests'] == 500
+
+
+class TestFlowExecutionTracking:
+ """Tests for flow execution tracking logic."""
+
+ def test_flow_execution_pricing_constant(self):
+ """Should have correct pricing constant ($0.005 = 0.5 cents)."""
+ assert QuotaService.FLOW_EXECUTION_PRICE_CENTS == Decimal("0.5")
+
+ def test_flow_execution_price_calculation(self):
+ """Should calculate price correctly for flow executions."""
+ # 2000 executions * 0.5 cents = 1000 cents = $10.00
+ executions = 2000
+ price_cents = int(executions * QuotaService.FLOW_EXECUTION_PRICE_CENTS)
+ assert price_cents == 1000
+
+
+class TestQuotaResetBehavior:
+ """Tests for quota reset behavior."""
+
+ def test_daily_api_quota_resets_each_day(self):
+ """Each day should get a fresh usage record."""
+ # This tests the conceptual model - each date gets its own record
+ # meaning quotas reset daily
+ day1 = date(2024, 6, 15)
+ day2 = date(2024, 6, 16)
+
+ # These would be separate records in the DB
+ usage_day1 = DailyApiUsage(date=day1, request_count=1000, quota_limit=1000)
+ usage_day2 = DailyApiUsage(date=day2, request_count=0, quota_limit=1000)
+
+ assert usage_day1.is_over_quota is True # Day 1 exhausted
+ assert usage_day2.is_over_quota is False # Day 2 fresh
+
+ def test_monthly_quota_resets_each_billing_cycle(self):
+ """Each billing period should get a fresh usage record."""
+ # December billing cycle (over limit - 101 > 100)
+ usage_dec = MonthlyQuotaUsage(year=2024, month=12, appointment_count=101, quota_limit=100)
+ # January billing cycle (new period - fresh start)
+ usage_jan = MonthlyQuotaUsage(year=2025, month=1, appointment_count=0, quota_limit=100)
+
+ assert usage_dec.is_over_quota is True # December over quota
+ assert usage_jan.is_over_quota is False # January fresh
+
+ def test_billing_period_based_on_subscription_start(self):
+ """
+ Billing period should be based on subscription.current_period_start,
+ not calendar month.
+ """
+ mock_tenant = Mock()
+
+ # Subscription started mid-month (Dec 15)
+ mock_subscription = Mock()
+ mock_subscription.is_active = True
+ mock_subscription.current_period_start = datetime(
+ 2024, 12, 15, tzinfo=dt_timezone.utc
+ )
+
+ with patch.object(
+ Subscription.objects, 'get', return_value=mock_subscription
+ ):
+ year, month = QuotaService.get_current_billing_period(mock_tenant)
+
+ # Should be December (when billing period started)
+ # not January (even if called in January)
+ assert year == 2024
+ assert month == 12
+
+
+class TestQuotaWarningEmail:
+ """Tests for quota warning email functionality."""
+
+ def test_send_quota_warning_email_builds_correct_context(self):
+ """Should build correct context for quota warning email."""
+ from smoothschedule.communication.messaging.email_service import EmailService
+ from smoothschedule.communication.messaging.email_types import EmailType
+
+ with patch(
+ 'smoothschedule.communication.messaging.email_service.send_system_email'
+ ) as mock_send:
+ mock_send.return_value = True
+
+ result = EmailService.send_quota_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_count=90,
+ quota_limit=100,
+ remaining=10,
+ percentage=90,
+ overage_price_cents=10,
+ )
+
+ assert result is True
+ mock_send.assert_called_once()
+
+ # Verify the context
+ call_args = mock_send.call_args
+ assert call_args[1]['email_type'] == EmailType.QUOTA_WARNING
+ assert call_args[1]['to_email'] == 'owner@business.com'
+
+ context = call_args[1]['context']
+ assert context['owner_name'] == 'Business Owner'
+ assert context['business_name'] == 'My Business'
+ assert context['usage_percentage'] == 90
+ assert context['appointments_used'] == 90
+ assert context['appointments_limit'] == 100
+ assert context['appointments_remaining'] == 10
+ assert context['overage_price'] == '$0.10'
+
+ def test_send_quota_warning_email_handles_failure(self):
+ """Should return False when email fails to send."""
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ with patch(
+ 'smoothschedule.communication.messaging.email_service.send_system_email'
+ ) as mock_send:
+ mock_send.return_value = False
+
+ result = EmailService.send_quota_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_count=90,
+ quota_limit=100,
+ remaining=10,
+ percentage=90,
+ overage_price_cents=10,
+ )
+
+ assert result is False
diff --git a/smoothschedule/smoothschedule/billing/tests/test_storage.py b/smoothschedule/smoothschedule/billing/tests/test_storage.py
new file mode 100644
index 00000000..fbadc598
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/tests/test_storage.py
@@ -0,0 +1,546 @@
+"""
+Tests for Storage Quota System.
+
+Tests for:
+- StorageUsage model properties
+- StorageService methods
+- Storage warning email
+
+All tests use mocks - no database access.
+"""
+from datetime import datetime, timezone as dt_timezone
+from unittest.mock import Mock, patch, MagicMock
+
+import pytest
+
+from smoothschedule.billing.models import StorageUsage
+from smoothschedule.billing.services.storage import StorageService, StorageStatus
+
+
+class TestStorageUsageModel:
+ """Tests for StorageUsage model properties."""
+
+ def test_current_size_mb_calculation(self):
+ """Should convert bytes to MB correctly."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 100, # 100 MB
+ quota_limit_mb=1000
+ )
+ assert usage.current_size_mb == 100.0
+
+ def test_current_size_gb_calculation(self):
+ """Should convert bytes to GB correctly."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1024 * 2, # 2 GB
+ quota_limit_mb=5000
+ )
+ assert usage.current_size_gb == 2.0
+
+ def test_quota_limit_gb_calculation(self):
+ """Should convert quota limit MB to GB correctly."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=0,
+ quota_limit_mb=2048 # 2 GB
+ )
+ assert usage.quota_limit_gb == 2.0
+
+ def test_is_unlimited_when_quota_zero(self):
+ """quota_limit_mb=0 means unlimited storage."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 500, # 500 MB
+ quota_limit_mb=0
+ )
+ assert usage.is_unlimited is True
+
+ def test_is_not_unlimited_when_quota_set(self):
+ """quota_limit_mb>0 means limited storage."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 500,
+ quota_limit_mb=1000
+ )
+ assert usage.is_unlimited is False
+
+ def test_usage_percentage_calculation(self):
+ """Should calculate percentage correctly."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 750, # 750 MB
+ quota_limit_mb=1000
+ )
+ assert usage.usage_percentage == 75.0
+
+ def test_usage_percentage_zero_when_unlimited(self):
+ """Should return 0% for unlimited plans."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1000,
+ quota_limit_mb=0
+ )
+ assert usage.usage_percentage == 0.0
+
+ def test_is_at_warning_threshold_at_90_percent(self):
+ """Should trigger warning at 90%."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 900, # 900 MB
+ quota_limit_mb=1000
+ )
+ assert usage.is_at_warning_threshold is True
+
+ def test_is_not_at_warning_threshold_below_90(self):
+ """Should not trigger warning below 90%."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 890, # 890 MB = 89%
+ quota_limit_mb=1000
+ )
+ assert usage.is_at_warning_threshold is False
+
+ def test_is_not_at_warning_threshold_when_unlimited(self):
+ """Should not trigger warning for unlimited plans."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 10000, # 10 GB
+ quota_limit_mb=0 # Unlimited
+ )
+ assert usage.is_at_warning_threshold is False
+
+ def test_is_over_quota(self):
+ """Should detect when over quota."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1100, # 1100 MB
+ quota_limit_mb=1000
+ )
+ assert usage.is_over_quota is True
+
+ def test_is_not_over_quota(self):
+ """Should detect when under quota."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 990, # 990 MB
+ quota_limit_mb=1000
+ )
+ assert usage.is_over_quota is False
+
+ def test_is_not_over_quota_when_unlimited(self):
+ """Should never be over quota for unlimited plans."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1024 * 100, # 100 GB
+ quota_limit_mb=0
+ )
+ assert usage.is_over_quota is False
+
+ def test_remaining_mb_calculation(self):
+ """Should calculate remaining correctly."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 750, # 750 MB
+ quota_limit_mb=1000
+ )
+ assert usage.remaining_mb == 250.0
+
+ def test_remaining_mb_none_when_unlimited(self):
+ """Should return None for unlimited plans."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1000,
+ quota_limit_mb=0
+ )
+ assert usage.remaining_mb is None
+
+ def test_remaining_mb_zero_when_over_quota(self):
+ """Should return 0 when over quota."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1200, # 1200 MB
+ quota_limit_mb=1000
+ )
+ assert usage.remaining_mb == 0.0
+
+ def test_overage_mb_when_over_quota(self):
+ """Should calculate overage MB based on peak_size_bytes."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 1200, # 1200 MB current
+ peak_size_bytes=1024 * 1024 * 1200, # 1200 MB peak (overage based on peak)
+ quota_limit_mb=1000
+ )
+ assert usage.overage_mb == 200.0
+
+ def test_overage_mb_zero_when_under_quota(self):
+ """Should be 0 when under quota."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 800, # 800 MB
+ peak_size_bytes=1024 * 1024 * 800, # Peak also 800 MB
+ quota_limit_mb=1000
+ )
+ assert usage.overage_mb == 0.0
+
+ def test_overage_amount_cents_calculation(self):
+ """Should calculate overage charges at $0.50 per GB based on peak usage."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 3072, # 3072 MB = 3 GB
+ peak_size_bytes=1024 * 1024 * 3072, # Peak also 3 GB
+ quota_limit_mb=1024 # 1 GB limit
+ )
+ # 2 GB overage * 50 cents = 100 cents = $1.00
+ assert usage.overage_amount_cents == 100
+
+ def test_overage_amount_cents_zero_when_under_quota(self):
+ """Should be 0 cents when under quota."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 500, # 500 MB
+ peak_size_bytes=1024 * 1024 * 500,
+ quota_limit_mb=1000
+ )
+ assert usage.overage_amount_cents == 0
+
+ def test_peak_size_tracking(self):
+ """Should track peak size in MB."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 500,
+ peak_size_bytes=1024 * 1024 * 750, # Peak was 750 MB
+ quota_limit_mb=1000
+ )
+ assert usage.peak_size_mb == 750.0
+
+
+class TestStorageUsageUpdateMeasurement:
+ """Tests for StorageUsage.update_measurement method."""
+
+ def test_update_measurement_updates_current_size(self):
+ """Should update current size."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=0,
+ peak_size_bytes=0,
+ quota_limit_mb=1000
+ )
+
+ new_size = 1024 * 1024 * 500 # 500 MB
+
+ with patch.object(usage, 'save'): # Mock save to avoid DB
+ usage.update_measurement(new_size)
+
+ assert usage.current_size_bytes == new_size
+
+ def test_update_measurement_updates_peak_if_larger(self):
+ """Should update peak if new size is larger."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=1024 * 1024 * 500,
+ peak_size_bytes=1024 * 1024 * 500,
+ quota_limit_mb=1000
+ )
+
+ new_size = 1024 * 1024 * 750 # 750 MB (larger than current peak)
+
+ with patch.object(usage, 'save'): # Mock save to avoid DB
+ usage.update_measurement(new_size)
+
+ assert usage.peak_size_bytes == new_size
+
+ def test_update_measurement_keeps_peak_if_smaller(self):
+ """Should keep existing peak if new size is smaller."""
+ original_peak = 1024 * 1024 * 750 # 750 MB
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=original_peak,
+ peak_size_bytes=original_peak,
+ quota_limit_mb=1000
+ )
+
+ new_size = 1024 * 1024 * 500 # 500 MB (smaller than peak)
+
+ with patch.object(usage, 'save'): # Mock save to avoid DB
+ usage.update_measurement(new_size)
+
+ assert usage.peak_size_bytes == original_peak
+ assert usage.current_size_bytes == new_size
+
+ def test_update_measurement_with_table_sizes(self):
+ """Should update table sizes if provided."""
+ usage = StorageUsage(
+ year=2024, month=6,
+ current_size_bytes=0,
+ peak_size_bytes=0,
+ quota_limit_mb=1000,
+ table_sizes={}
+ )
+
+ table_sizes = {
+ 'users': 1024 * 1024 * 100,
+ 'appointments': 1024 * 1024 * 200,
+ }
+
+ with patch.object(usage, 'save'): # Mock save to avoid DB
+ usage.update_measurement(1024 * 1024 * 300, table_sizes)
+
+ assert usage.table_sizes == table_sizes
+
+
+class TestStorageServiceLimitRetrieval:
+ """Tests for StorageService limit retrieval."""
+
+ def test_get_storage_quota_limit_returns_integer(self):
+ """Should return integer limit from entitlements."""
+ mock_tenant = Mock()
+
+ with patch(
+ 'smoothschedule.billing.services.storage.EntitlementService.get_limit',
+ return_value=1000
+ ):
+ limit = StorageService.get_storage_quota_limit(mock_tenant)
+ assert limit == 1000
+
+ def test_get_storage_quota_limit_returns_zero_for_none(self):
+ """Should return 0 (unlimited) when entitlement returns None."""
+ mock_tenant = Mock()
+
+ with patch(
+ 'smoothschedule.billing.services.storage.EntitlementService.get_limit',
+ return_value=None
+ ):
+ limit = StorageService.get_storage_quota_limit(mock_tenant)
+ assert limit == 0
+
+
+class TestStorageServiceMeasureSchema:
+ """Tests for StorageService.measure_schema_size."""
+
+ def test_measure_schema_size_returns_tuple(self):
+ """Should return (total_size, table_sizes) tuple."""
+ mock_cursor = MagicMock()
+ mock_cursor.fetchall.return_value = [
+ ('users', 1024 * 1024 * 100),
+ ('appointments', 1024 * 1024 * 200),
+ ]
+
+ with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
+ mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
+
+ total_size, table_sizes = StorageService.measure_schema_size('tenant_schema')
+
+ assert total_size == 1024 * 1024 * 300 # 300 MB total
+ assert table_sizes == {
+ 'users': 1024 * 1024 * 100,
+ 'appointments': 1024 * 1024 * 200,
+ }
+
+ def test_measure_schema_size_handles_empty_schema(self):
+ """Should handle empty schema gracefully."""
+ mock_cursor = MagicMock()
+ mock_cursor.fetchall.return_value = []
+
+ with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
+ mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
+
+ total_size, table_sizes = StorageService.measure_schema_size('empty_schema')
+
+ assert total_size == 0
+ assert table_sizes == {}
+
+ def test_measure_schema_size_handles_exception(self):
+ """Should return zeros on exception."""
+ with patch('smoothschedule.billing.services.storage.connection') as mock_conn:
+ mock_conn.cursor.return_value.__enter__.side_effect = Exception("DB error")
+
+ total_size, table_sizes = StorageService.measure_schema_size('bad_schema')
+
+ assert total_size == 0
+ assert table_sizes == {}
+
+
+class TestStorageServiceGetStatus:
+ """Tests for StorageService.get_storage_status."""
+
+ def test_get_storage_status_returns_named_tuple(self):
+ """Should return StorageStatus named tuple with all fields."""
+ mock_tenant = Mock()
+ mock_tenant.schema_name = 'tenant_demo'
+
+ mock_usage = Mock()
+ mock_usage.year = 2024
+ mock_usage.month = 6
+ mock_usage.current_size_bytes = 1024 * 1024 * 500
+ mock_usage.current_size_mb = 500.0
+ mock_usage.current_size_gb = 0.488
+ mock_usage.peak_size_bytes = 1024 * 1024 * 600
+ mock_usage.peak_size_mb = 600.0
+ mock_usage.quota_limit_mb = 1000
+ mock_usage.quota_limit_gb = 0.977
+ mock_usage.is_unlimited = False
+ mock_usage.usage_percentage = 50.0
+ mock_usage.remaining_mb = 500.0
+ mock_usage.is_at_warning_threshold = False
+ mock_usage.is_over_quota = False
+ mock_usage.overage_mb = 0.0
+ mock_usage.overage_amount_cents = 0
+ mock_usage.warning_email_sent = False
+ mock_usage.last_measured_at = datetime(2024, 6, 15, 10, 0, 0, tzinfo=dt_timezone.utc)
+ mock_usage.table_sizes = {'users': 1000000}
+
+ with patch.object(
+ StorageService, 'get_current_billing_period', return_value=(2024, 6)
+ ), patch.object(
+ StorageService, 'get_or_create_storage_usage', return_value=mock_usage
+ ):
+ status = StorageService.get_storage_status(mock_tenant)
+
+ assert isinstance(status, StorageStatus)
+ assert status.year == 2024
+ assert status.month == 6
+ assert status.current_size_mb == 500.0
+ assert status.quota_limit_mb == 1000
+ assert status.is_unlimited is False
+ assert status.is_at_warning_threshold is False
+
+
+class TestStorageStatusNamedTuple:
+ """Tests for StorageStatus named tuple fields."""
+
+ def test_storage_status_includes_all_required_fields(self):
+ """StorageStatus should include all required fields."""
+ fields = StorageStatus._fields
+
+ expected_fields = {
+ 'year', 'month', 'current_size_bytes', 'current_size_mb',
+ 'current_size_gb', 'peak_size_bytes', 'peak_size_mb',
+ 'quota_limit_mb', 'quota_limit_gb', 'is_unlimited',
+ 'usage_percentage', 'remaining_mb', 'is_at_warning_threshold',
+ 'is_over_quota', 'overage_mb', 'overage_amount_cents',
+ 'warning_email_sent', 'last_measured_at', 'table_sizes',
+ }
+ assert set(fields) == expected_fields
+
+
+class TestStorageOveragePricing:
+ """Tests for storage overage pricing constants."""
+
+ def test_overage_price_constant(self):
+ """Should have correct pricing constant ($0.50 = 50 cents per GB)."""
+ assert StorageService.OVERAGE_PRICE_CENTS_PER_GB == 50
+
+ def test_warning_threshold_constant(self):
+ """Should have correct warning threshold (90%)."""
+ assert StorageService.WARNING_THRESHOLD_PERCENT == 90
+
+
+class TestStorageWarningEmail:
+ """Tests for storage warning email functionality."""
+
+ def test_send_storage_warning_email_builds_correct_context(self):
+ """Should build correct context for storage warning email."""
+ from smoothschedule.communication.messaging.email_service import EmailService
+ from smoothschedule.communication.messaging.email_types import EmailType
+
+ with patch(
+ 'smoothschedule.communication.messaging.email_service.send_system_email'
+ ) as mock_send:
+ mock_send.return_value = True
+
+ result = EmailService.send_storage_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_mb=900.0,
+ quota_limit_mb=1000,
+ remaining_mb=100.0,
+ percentage=90,
+ overage_price_cents_per_gb=50,
+ )
+
+ assert result is True
+ mock_send.assert_called_once()
+
+ # Verify the context
+ call_args = mock_send.call_args
+ assert call_args[1]['email_type'] == EmailType.STORAGE_WARNING
+ assert call_args[1]['to_email'] == 'owner@business.com'
+
+ context = call_args[1]['context']
+ assert context['owner_name'] == 'Business Owner'
+ assert context['business_name'] == 'My Business'
+ assert context['usage_percentage'] == 90
+ assert context['storage_used'] == '900.0 MB'
+ assert context['storage_limit'] == '1000 MB'
+ assert context['storage_remaining'] == '100.0 MB'
+ assert context['overage_price'] == '$0.50'
+
+ def test_send_storage_warning_email_formats_gb_sizes(self):
+ """Should format sizes in GB when >= 1024 MB."""
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ with patch(
+ 'smoothschedule.communication.messaging.email_service.send_system_email'
+ ) as mock_send:
+ mock_send.return_value = True
+
+ EmailService.send_storage_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_mb=9216.0, # 9 GB
+ quota_limit_mb=10240, # 10 GB
+ remaining_mb=1024.0, # 1 GB
+ percentage=90,
+ overage_price_cents_per_gb=50,
+ )
+
+ context = mock_send.call_args[1]['context']
+ assert context['storage_used'] == '9.0 GB'
+ assert context['storage_limit'] == '10.0 GB'
+ assert context['storage_remaining'] == '1.0 GB'
+
+ def test_send_storage_warning_email_handles_failure(self):
+ """Should return False when email fails to send."""
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ with patch(
+ 'smoothschedule.communication.messaging.email_service.send_system_email'
+ ) as mock_send:
+ mock_send.return_value = False
+
+ result = EmailService.send_storage_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_mb=900.0,
+ quota_limit_mb=1000,
+ remaining_mb=100.0,
+ percentage=90,
+ overage_price_cents_per_gb=50,
+ )
+
+ assert result is False
+
+
+class TestStorageFormatSize:
+ """Tests for StorageService.format_size helper."""
+
+ def test_format_size_bytes(self):
+ """Should format bytes correctly."""
+ assert StorageService.format_size(500) == "500 bytes"
+
+ def test_format_size_kb(self):
+ """Should format KB correctly."""
+ assert StorageService.format_size(1024 * 2) == "2.0 KB"
+
+ def test_format_size_mb(self):
+ """Should format MB correctly."""
+ assert StorageService.format_size(1024 * 1024 * 100) == "100.0 MB"
+
+ def test_format_size_gb(self):
+ """Should format GB correctly."""
+ assert StorageService.format_size(1024 * 1024 * 1024 * 2) == "2.0 GB"
diff --git a/smoothschedule/smoothschedule/billing/tests/test_tasks.py b/smoothschedule/smoothschedule/billing/tests/test_tasks.py
index 78bfaba5..01ea17b8 100644
--- a/smoothschedule/smoothschedule/billing/tests/test_tasks.py
+++ b/smoothschedule/smoothschedule/billing/tests/test_tasks.py
@@ -2,12 +2,16 @@
Tests for billing Celery tasks.
"""
from datetime import timedelta
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, patch, MagicMock
import pytest
from django.utils import timezone
from smoothschedule.billing.models import TenantCustomTier
-from smoothschedule.billing.tasks import check_subscription_grace_periods
+from smoothschedule.billing.tasks import (
+ check_subscription_grace_periods,
+ measure_all_tenant_storage,
+ measure_tenant_storage,
+)
class TestCheckSubscriptionGracePeriods:
@@ -204,3 +208,100 @@ class TestCheckSubscriptionGracePeriods:
assert result['grace_periods_started'] == 1 # Only ct2 succeeded
assert len(result['errors']) == 1
assert "Database error" in result['errors'][0]
+
+
+class TestMeasureAllTenantStorage:
+ """Tests for measure_all_tenant_storage Celery task."""
+
+ def test_task_returns_measurement_results(self):
+ """Should return summary of measurements taken."""
+ mock_usage1 = Mock()
+ mock_usage1.warning_email_sent = False
+ mock_usage1.warning_email_sent_at = None
+
+ mock_usage2 = Mock()
+ mock_usage2.warning_email_sent = True
+ mock_usage2.warning_email_sent_at = timezone.now()
+
+ with patch(
+ 'smoothschedule.billing.services.storage.StorageService.measure_all_tenants',
+ return_value=[mock_usage1, mock_usage2]
+ ):
+ result = measure_all_tenant_storage()
+
+ assert result['tenants_measured'] == 2
+ assert result['warnings_sent'] == 1
+ assert result['errors'] == []
+
+ def test_task_handles_errors_gracefully(self):
+ """Should log errors and return error summary."""
+ with patch(
+ 'smoothschedule.billing.services.storage.StorageService.measure_all_tenants',
+ side_effect=Exception("Database connection failed")
+ ):
+ result = measure_all_tenant_storage()
+
+ assert result['tenants_measured'] == 0
+ assert len(result['errors']) == 1
+ assert "Database connection failed" in result['errors'][0]
+
+
+class TestMeasureTenantStorage:
+ """Tests for measure_tenant_storage Celery task."""
+
+ def test_task_returns_measurement_data(self):
+ """Should return storage measurement for specific tenant."""
+ mock_tenant = Mock()
+ mock_tenant.name = "Test Business"
+
+ mock_usage = Mock()
+ mock_usage.current_size_mb = 500.0
+ mock_usage.quota_limit_mb = 1000
+ mock_usage.usage_percentage = 50.0
+ mock_usage.is_over_quota = False
+
+ with patch(
+ 'smoothschedule.identity.core.models.Tenant.objects.get',
+ return_value=mock_tenant
+ ), patch(
+ 'smoothschedule.billing.services.storage.StorageService.measure_tenant_storage',
+ return_value=mock_usage
+ ):
+ result = measure_tenant_storage(tenant_id=1)
+
+ assert result['tenant_id'] == 1
+ assert result['tenant_name'] == "Test Business"
+ assert result['current_size_mb'] == 500.0
+ assert result['quota_limit_mb'] == 1000
+ assert result['usage_percentage'] == 50.0
+ assert result['is_over_quota'] is False
+
+ def test_task_handles_tenant_not_found(self):
+ """Should return error when tenant doesn't exist."""
+ from smoothschedule.identity.core.models import Tenant
+
+ with patch.object(
+ Tenant.objects, 'get',
+ side_effect=Tenant.DoesNotExist
+ ):
+ result = measure_tenant_storage(tenant_id=999)
+
+ assert 'error' in result
+ assert '999' in result['error']
+
+ def test_task_handles_measurement_error(self):
+ """Should return error when measurement fails."""
+ mock_tenant = Mock()
+ mock_tenant.name = "Test Business"
+
+ with patch(
+ 'smoothschedule.identity.core.models.Tenant.objects.get',
+ return_value=mock_tenant
+ ), patch(
+ 'smoothschedule.billing.services.storage.StorageService.measure_tenant_storage',
+ side_effect=Exception("Schema not found")
+ ):
+ result = measure_tenant_storage(tenant_id=1)
+
+ assert 'error' in result
+ assert "Schema not found" in result['error']
diff --git a/smoothschedule/smoothschedule/communication/messaging/default_templates.py b/smoothschedule/smoothschedule/communication/messaging/default_templates.py
index 86f8545e..198a4d13 100644
--- a/smoothschedule/smoothschedule/communication/messaging/default_templates.py
+++ b/smoothschedule/smoothschedule/communication/messaging/default_templates.py
@@ -3,6 +3,14 @@ Default Email Templates
Provides default Puck templates for all email types.
These are used when a tenant hasn't customized their templates.
+
+TODO: Implement PUCK Platform email templates for platform-level communications:
+ - Tenant invitation emails (currently using Django template in platform_admin/templates/)
+ - Platform announcements
+ - Subscription billing notifications
+ - Trial expiration warnings
+ - Plan upgrade/downgrade confirmations
+ These should be visually editable like tenant templates but managed at the platform level.
"""
DEFAULT_TEMPLATES = {
@@ -925,6 +933,180 @@ DEFAULT_TEMPLATES = {
'root': {}
}
},
+
+ # =========================================================================
+ # Quota Warning
+ # =========================================================================
+ 'quota_warning': {
+ 'subject_template': 'Approaching Appointment Limit - {{ usage_percentage }}% Used',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': 'SmoothSchedule',
+ 'preheader': 'Your appointment quota is almost full'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Appointment Quota Warning',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "Hi {{ owner_name }},\n\nYou've used {{ usage_percentage }}% of your monthly appointment quota for {{ business_name }}. Here's your current usage:",
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Appointments Used: {{ appointments_used }} of {{ appointments_limit }}
Billing Period: {{ billing_period }}
Remaining: {{ appointments_remaining }} appointments',
+ 'backgroundColor': '#fef3c7'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Once you exceed your quota, additional appointments will be billed at {{ overage_price }} each.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Consider upgrading your plan to get more appointments and avoid overage charges.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Upgrade Plan',
+ 'href': '{{ upgrade_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'sm'}
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'View Usage Details',
+ 'href': '{{ usage_link }}',
+ 'variant': 'secondary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': 'support@smoothschedule.com'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Storage Warning
+ # =========================================================================
+ 'storage_warning': {
+ 'subject_template': 'Approaching Storage Limit - {{ usage_percentage }}% Used',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': 'SmoothSchedule',
+ 'preheader': 'Your database storage quota is almost full'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Storage Quota Warning',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "Hi {{ owner_name }},\n\nYou've used {{ usage_percentage }}% of your database storage quota for {{ business_name }}. Here's your current usage:",
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Storage Used: {{ storage_used }} of {{ storage_limit }}
Billing Period: {{ billing_period }}
Remaining: {{ storage_remaining }}',
+ 'backgroundColor': '#fef3c7'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Once you exceed your quota, additional storage will be billed at {{ overage_price }} per GB.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Consider upgrading your plan to get more storage and avoid overage charges.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Upgrade Plan',
+ 'href': '{{ upgrade_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'sm'}
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'View Usage Details',
+ 'href': '{{ usage_link }}',
+ 'variant': 'secondary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': 'support@smoothschedule.com'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
}
diff --git a/smoothschedule/smoothschedule/communication/messaging/email_service.py b/smoothschedule/smoothschedule/communication/messaging/email_service.py
index a4d17692..eac94596 100644
--- a/smoothschedule/smoothschedule/communication/messaging/email_service.py
+++ b/smoothschedule/smoothschedule/communication/messaging/email_service.py
@@ -17,6 +17,20 @@ Usage:
'appointment_time': '10:00 AM',
}
)
+
+ # Using the EmailService class
+ from smoothschedule.communication.messaging.email_service import EmailService
+
+ EmailService.send_quota_warning_email(
+ to_email='owner@business.com',
+ to_name='Business Owner',
+ business_name='My Business',
+ current_count=90,
+ quota_limit=100,
+ remaining=10,
+ percentage=90,
+ overage_price_cents=10,
+ )
"""
import logging
from typing import Dict, Any, Optional, List
@@ -293,3 +307,140 @@ def send_html_email(
html_message=html_message,
fail_silently=fail_silently,
)
+
+
+class EmailService:
+ """
+ Service class for sending various system emails.
+
+ Provides class methods for different email types with appropriate
+ context construction.
+ """
+
+ @classmethod
+ def send_quota_warning_email(
+ cls,
+ to_email: str,
+ to_name: str,
+ business_name: str,
+ current_count: int,
+ quota_limit: int,
+ remaining: int,
+ percentage: int,
+ overage_price_cents: int,
+ ) -> bool:
+ """
+ Send a quota warning email to a business owner/manager.
+
+ Args:
+ to_email: Recipient email address
+ to_name: Recipient name (for personalization)
+ business_name: Business name
+ current_count: Current appointment count
+ quota_limit: Maximum appointments allowed
+ remaining: Remaining appointments before overage
+ percentage: Current usage percentage (e.g., 90)
+ overage_price_cents: Price per overage appointment in cents
+
+ Returns:
+ True if email sent successfully, False otherwise
+ """
+ from django.conf import settings
+
+ # Format billing period for display
+ from django.utils import timezone
+ now = timezone.now()
+ billing_period = now.strftime("%B %Y")
+
+ # Build context for template
+ context = {
+ "owner_name": to_name,
+ "business_name": business_name,
+ "usage_percentage": percentage,
+ "appointments_used": current_count,
+ "appointments_limit": quota_limit,
+ "appointments_remaining": remaining,
+ "billing_period": billing_period,
+ "overage_price": f"${overage_price_cents / 100:.2f}",
+ "upgrade_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing",
+ "usage_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing/usage",
+ }
+
+ return send_system_email(
+ email_type=EmailType.QUOTA_WARNING,
+ to_email=to_email,
+ context=context,
+ fail_silently=True,
+ )
+
+ @classmethod
+ def send_storage_warning_email(
+ cls,
+ to_email: str,
+ to_name: str,
+ business_name: str,
+ current_mb: float,
+ quota_limit_mb: int,
+ remaining_mb: float,
+ percentage: int,
+ overage_price_cents_per_gb: int,
+ ) -> bool:
+ """
+ Send a storage warning email to a business owner/manager.
+
+ Args:
+ to_email: Recipient email address
+ to_name: Recipient name (for personalization)
+ business_name: Business name
+ current_mb: Current storage usage in MB
+ quota_limit_mb: Storage limit in MB
+ remaining_mb: Remaining storage before overage
+ percentage: Current usage percentage (e.g., 90)
+ overage_price_cents_per_gb: Price per GB overage in cents
+
+ Returns:
+ True if email sent successfully, False otherwise
+ """
+ from django.conf import settings
+
+ # Format billing period for display
+ from django.utils import timezone
+ now = timezone.now()
+ billing_period = now.strftime("%B %Y")
+
+ # Format sizes for display
+ if quota_limit_mb >= 1024:
+ quota_display = f"{quota_limit_mb / 1024:.1f} GB"
+ else:
+ quota_display = f"{quota_limit_mb} MB"
+
+ if current_mb >= 1024:
+ current_display = f"{current_mb / 1024:.1f} GB"
+ else:
+ current_display = f"{current_mb:.1f} MB"
+
+ if remaining_mb >= 1024:
+ remaining_display = f"{remaining_mb / 1024:.1f} GB"
+ else:
+ remaining_display = f"{remaining_mb:.1f} MB"
+
+ # Build context for template
+ context = {
+ "owner_name": to_name,
+ "business_name": business_name,
+ "usage_percentage": percentage,
+ "storage_used": current_display,
+ "storage_limit": quota_display,
+ "storage_remaining": remaining_display,
+ "billing_period": billing_period,
+ "overage_price": f"${overage_price_cents_per_gb / 100:.2f}",
+ "upgrade_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing",
+ "usage_link": f"{getattr(settings, 'FRONTEND_URL', 'https://smoothschedule.com')}/settings/billing/usage",
+ }
+
+ return send_system_email(
+ email_type=EmailType.STORAGE_WARNING,
+ to_email=to_email,
+ context=context,
+ fail_silently=True,
+ )
diff --git a/smoothschedule/smoothschedule/communication/messaging/email_tags.py b/smoothschedule/smoothschedule/communication/messaging/email_tags.py
index 6cefa759..ec493609 100644
--- a/smoothschedule/smoothschedule/communication/messaging/email_tags.py
+++ b/smoothschedule/smoothschedule/communication/messaging/email_tags.py
@@ -87,6 +87,29 @@ TICKET_TAGS: Dict[str, str] = {
'assignee_name': 'Name of assigned staff',
}
+# Billing/quota-related tags
+BILLING_TAGS: Dict[str, str] = {
+ # Owner info
+ 'owner_name': 'Business owner/recipient name',
+
+ # Appointment quota
+ 'usage_percentage': 'Current usage percentage (e.g., 90)',
+ 'appointments_used': 'Number of appointments used this period',
+ 'appointments_limit': 'Maximum appointments allowed',
+ 'appointments_remaining': 'Remaining appointments before overage',
+ 'billing_period': 'Current billing period (e.g., January 2025)',
+ 'overage_price': 'Price per overage item (formatted with $)',
+
+ # Storage quota
+ 'storage_used': 'Current storage usage (formatted, e.g., 450 MB)',
+ 'storage_limit': 'Storage limit (formatted, e.g., 500 MB)',
+ 'storage_remaining': 'Remaining storage (formatted)',
+
+ # Action links
+ 'upgrade_link': 'Link to upgrade subscription plan',
+ 'usage_link': 'Link to view usage details',
+}
+
# =============================================================================
# Tag Mapping by Email Type
@@ -99,6 +122,7 @@ def get_tags_for_category(category: str) -> Dict[str, str]:
'contract': CONTRACT_TAGS,
'payment': PAYMENT_TAGS,
'ticket': TICKET_TAGS,
+ 'billing': BILLING_TAGS,
'welcome': {}, # Only base tags
}
return category_tags.get(category, {})
@@ -253,6 +277,8 @@ def get_tag_info_for_email_type(email_type: EmailType) -> List[Dict[str, str]]:
category = 'Payment'
elif tag in TICKET_TAGS:
category = 'Support Ticket'
+ elif tag in BILLING_TAGS:
+ category = 'Billing & Quota'
else:
category = 'Other'
@@ -284,6 +310,7 @@ def get_all_tag_info() -> List[Dict[str, str]]:
(CONTRACT_TAGS, 'Contract'),
(PAYMENT_TAGS, 'Payment'),
(TICKET_TAGS, 'Support Ticket'),
+ (BILLING_TAGS, 'Billing & Quota'),
]
seen_tags = set()
diff --git a/smoothschedule/smoothschedule/communication/messaging/email_types.py b/smoothschedule/smoothschedule/communication/messaging/email_types.py
index 00c4ddaf..4ccafa63 100644
--- a/smoothschedule/smoothschedule/communication/messaging/email_types.py
+++ b/smoothschedule/smoothschedule/communication/messaging/email_types.py
@@ -79,6 +79,15 @@ class EmailType(str, Enum):
TICKET_RESOLVED = 'ticket_resolved'
"""Sent when ticket is marked as resolved."""
+ # ==========================================================================
+ # Billing / Quota
+ # ==========================================================================
+ QUOTA_WARNING = 'quota_warning'
+ """Sent to business owner when appointment quota reaches 90%."""
+
+ STORAGE_WARNING = 'storage_warning'
+ """Sent to business owner when database storage reaches 90%."""
+
# ==========================================================================
# Utility Methods
# ==========================================================================
@@ -112,6 +121,9 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'ticket',
cls.TICKET_REPLY: 'ticket',
cls.TICKET_RESOLVED: 'ticket',
+ # Billing
+ cls.QUOTA_WARNING: 'billing',
+ cls.STORAGE_WARNING: 'billing',
}
return categories.get(email_type, 'other')
@@ -134,6 +146,8 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'Ticket Assigned',
cls.TICKET_REPLY: 'Ticket Reply',
cls.TICKET_RESOLVED: 'Ticket Resolved',
+ cls.QUOTA_WARNING: 'Quota Warning',
+ cls.STORAGE_WARNING: 'Storage Warning',
}
return display_names.get(email_type, email_type.value.replace('_', ' ').title())
@@ -156,6 +170,8 @@ class EmailType(str, Enum):
cls.TICKET_ASSIGNED: 'Notifies staff when a support ticket is assigned',
cls.TICKET_REPLY: 'Sent when someone replies to a support ticket',
cls.TICKET_RESOLVED: 'Sent when a support ticket is resolved',
+ cls.QUOTA_WARNING: 'Sent to business owner when appointment quota reaches 90%',
+ cls.STORAGE_WARNING: 'Sent to business owner when database storage reaches 90%',
}
return descriptions.get(email_type, '')
diff --git a/smoothschedule/smoothschedule/identity/users/models.py b/smoothschedule/smoothschedule/identity/users/models.py
index 09b843bf..31997445 100644
--- a/smoothschedule/smoothschedule/identity/users/models.py
+++ b/smoothschedule/smoothschedule/identity/users/models.py
@@ -250,35 +250,6 @@ class User(AbstractUser):
return True
return False
- def can_approve_plugins(self):
- """
- Check if user can approve/publish plugins to marketplace.
- Only platform users with explicit permission (granted by superuser).
- """
- # Superusers can always approve
- if self.role == self.Role.SUPERUSER:
- return True
- # Platform managers/support can approve if granted permission
- if self.role in [self.Role.PLATFORM_MANAGER, self.Role.PLATFORM_SUPPORT]:
- return self.permissions.get('can_approve_plugins', False)
- # All others cannot approve
- return False
-
- def can_whitelist_urls(self):
- """
- Check if user can whitelist URLs for plugin API calls.
- Only platform users with explicit permission (granted by superuser).
- Can whitelist both per-user and platform-wide URLs.
- """
- # Superusers can always whitelist
- if self.role == self.Role.SUPERUSER:
- return True
- # Platform managers/support can whitelist if granted permission
- if self.role in [self.Role.PLATFORM_MANAGER, self.Role.PLATFORM_SUPPORT]:
- return self.permissions.get('can_whitelist_urls', False)
- # All others cannot whitelist
- return False
-
def can_self_approve_time_off(self):
"""
Check if user can self-approve time off requests.
diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_models.py b/smoothschedule/smoothschedule/identity/users/tests/test_models.py
index 49bdd53f..9bbaeb6e 100644
--- a/smoothschedule/smoothschedule/identity/users/tests/test_models.py
+++ b/smoothschedule/smoothschedule/identity/users/tests/test_models.py
@@ -3,7 +3,7 @@ Unit tests for the User model.
Tests cover:
1. Role-related methods (is_platform_user, is_tenant_user, can_invite_staff, etc.)
-2. Permission checking methods (can_access_tickets, can_approve_plugins, etc.)
+2. Permission checking methods (can_access_tickets, can_self_approve_time_off, etc.)
3. Property methods (full_name)
4. The save() method validation logic (tenant requirements for different roles)
@@ -198,66 +198,6 @@ class TestCanAccessTickets:
assert user.can_access_tickets() is True
-# =============================================================================
-# Plugin Approval Permission Tests
-# =============================================================================
-
-class TestCanApprovePlugins:
- """Test can_approve_plugins() method."""
-
- def test_returns_true_for_superuser(self):
- user = create_user_instance(User.Role.SUPERUSER)
- assert user.can_approve_plugins() is True
-
- def test_returns_true_for_platform_manager_with_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER, permissions={'can_approve_plugins': True})
- assert user.can_approve_plugins() is True
-
- def test_returns_false_for_platform_manager_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER)
- assert user.can_approve_plugins() is False
-
- def test_returns_true_for_platform_support_with_permission(self):
- user = create_user_instance(User.Role.PLATFORM_SUPPORT, permissions={'can_approve_plugins': True})
- assert user.can_approve_plugins() is True
-
- def test_returns_false_for_platform_support_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_SUPPORT)
- assert user.can_approve_plugins() is False
-
- def test_returns_false_for_tenant_owner(self):
- user = create_user_instance(User.Role.TENANT_OWNER)
- assert user.can_approve_plugins() is False
-
-
-# =============================================================================
-# URL Whitelist Permission Tests
-# =============================================================================
-
-class TestCanWhitelistUrls:
- """Test can_whitelist_urls() method."""
-
- def test_returns_true_for_superuser(self):
- user = create_user_instance(User.Role.SUPERUSER)
- assert user.can_whitelist_urls() is True
-
- def test_returns_true_for_platform_manager_with_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER, permissions={'can_whitelist_urls': True})
- assert user.can_whitelist_urls() is True
-
- def test_returns_false_for_platform_manager_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER)
- assert user.can_whitelist_urls() is False
-
- def test_returns_true_for_platform_support_with_permission(self):
- user = create_user_instance(User.Role.PLATFORM_SUPPORT, permissions={'can_whitelist_urls': True})
- assert user.can_whitelist_urls() is True
-
- def test_returns_false_for_tenant_owner(self):
- user = create_user_instance(User.Role.TENANT_OWNER)
- assert user.can_whitelist_urls() is False
-
-
# =============================================================================
# Time Off Self-Approval Tests
# =============================================================================
diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
index 6b25c879..0ba0ae9c 100644
--- a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
+++ b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
@@ -268,99 +268,6 @@ class TestCanAccessTickets:
assert user.can_access_tickets() is True
-# =============================================================================
-# Plugin Approval Permission Tests
-# =============================================================================
-
-class TestCanApprovePlugins:
- """Test can_approve_plugins() method."""
-
- def test_returns_true_for_superuser(self):
- user = create_user_instance(User.Role.SUPERUSER)
- assert user.can_approve_plugins() is True
-
- def test_returns_true_for_platform_manager_with_permission(self):
- user = create_user_instance(
- User.Role.PLATFORM_MANAGER,
- permissions={'can_approve_plugins': True}
- )
- assert user.can_approve_plugins() is True
-
- def test_returns_false_for_platform_manager_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER)
- assert user.can_approve_plugins() is False
-
- def test_returns_true_for_platform_support_with_permission(self):
- user = create_user_instance(
- User.Role.PLATFORM_SUPPORT,
- permissions={'can_approve_plugins': True}
- )
- assert user.can_approve_plugins() is True
-
- def test_returns_false_for_platform_support_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_SUPPORT)
- assert user.can_approve_plugins() is False
-
- def test_returns_false_for_platform_sales(self):
- user = create_user_instance(User.Role.PLATFORM_SALES)
- assert user.can_approve_plugins() is False
-
- def test_returns_false_for_tenant_owner(self):
- user = create_user_instance(User.Role.TENANT_OWNER)
- assert user.can_approve_plugins() is False
-
-
- def test_returns_false_for_customer(self):
- user = create_user_instance(User.Role.CUSTOMER)
- assert user.can_approve_plugins() is False
-
-
-# =============================================================================
-# URL Whitelist Permission Tests
-# =============================================================================
-
-class TestCanWhitelistUrls:
- """Test can_whitelist_urls() method."""
-
- def test_returns_true_for_superuser(self):
- user = create_user_instance(User.Role.SUPERUSER)
- assert user.can_whitelist_urls() is True
-
- def test_returns_true_for_platform_manager_with_permission(self):
- user = create_user_instance(
- User.Role.PLATFORM_MANAGER,
- permissions={'can_whitelist_urls': True}
- )
- assert user.can_whitelist_urls() is True
-
- def test_returns_false_for_platform_manager_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_MANAGER)
- assert user.can_whitelist_urls() is False
-
- def test_returns_true_for_platform_support_with_permission(self):
- user = create_user_instance(
- User.Role.PLATFORM_SUPPORT,
- permissions={'can_whitelist_urls': True}
- )
- assert user.can_whitelist_urls() is True
-
- def test_returns_false_for_platform_support_without_permission(self):
- user = create_user_instance(User.Role.PLATFORM_SUPPORT)
- assert user.can_whitelist_urls() is False
-
- def test_returns_false_for_platform_sales(self):
- user = create_user_instance(User.Role.PLATFORM_SALES)
- assert user.can_whitelist_urls() is False
-
- def test_returns_false_for_tenant_owner(self):
- user = create_user_instance(User.Role.TENANT_OWNER)
- assert user.can_whitelist_urls() is False
-
- def test_returns_false_for_customer(self):
- user = create_user_instance(User.Role.CUSTOMER)
- assert user.can_whitelist_urls() is False
-
-
# =============================================================================
# Time Off Self-Approval Tests
# =============================================================================
diff --git a/smoothschedule/smoothschedule/platform/admin/migrations/0015_add_platform_email_template.py b/smoothschedule/smoothschedule/platform/admin/migrations/0015_add_platform_email_template.py
new file mode 100644
index 00000000..7ec53fe4
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/admin/migrations/0015_add_platform_email_template.py
@@ -0,0 +1,35 @@
+# Generated by Django 5.2.8 on 2025-12-31 14:44
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0014_add_routing_mode_and_email_models'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='PlatformEmailTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email_type', models.CharField(choices=[('tenant_invitation', 'Tenant Invitation'), ('trial_expiration_warning', 'Trial Expiration Warning'), ('trial_expired', 'Trial Expired'), ('plan_upgrade', 'Plan Upgrade Confirmation'), ('plan_downgrade', 'Plan Downgrade Confirmation'), ('subscription_cancelled', 'Subscription Cancelled'), ('payment_failed', 'Payment Failed'), ('payment_succeeded', 'Payment Succeeded')], help_text='The type of platform email this template is for', max_length=50, unique=True)),
+ ('subject_template', models.CharField(help_text='Email subject line with {{ tag }} placeholders', max_length=500)),
+ ('puck_data', models.JSONField(default=dict, help_text='Puck editor JSON data for the email body')),
+ ('is_active', models.BooleanField(default=True, help_text='Whether this template is currently in use')),
+ ('is_customized', models.BooleanField(default=False, help_text='Whether this template has been customized from the default')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('created_by', models.ForeignKey(blank=True, help_text='User who created/last modified this template', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_email_templates_created', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Platform Email Template',
+ 'verbose_name_plural': 'Platform Email Templates',
+ 'ordering': ['email_type'],
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/platform/admin/migrations/0016_add_platform_staff_invitation.py b/smoothschedule/smoothschedule/platform/admin/migrations/0016_add_platform_staff_invitation.py
new file mode 100644
index 00000000..b64fccd6
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/admin/migrations/0016_add_platform_staff_invitation.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.2.8 on 2026-01-01 02:57
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0015_add_platform_email_template'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='platformemailtemplate',
+ name='email_type',
+ field=models.CharField(choices=[('tenant_invitation', 'Tenant Invitation'), ('trial_expiration_warning', 'Trial Expiration Warning'), ('trial_expired', 'Trial Expired'), ('plan_upgrade', 'Plan Upgrade Confirmation'), ('plan_downgrade', 'Plan Downgrade Confirmation'), ('subscription_cancelled', 'Subscription Cancelled'), ('payment_failed', 'Payment Failed'), ('payment_succeeded', 'Payment Succeeded'), ('platform_staff_invitation', 'Platform Staff Invitation'), ('platform_staff_welcome', 'Platform Staff Welcome')], help_text='The type of platform email this template is for', max_length=50, unique=True),
+ ),
+ migrations.CreateModel(
+ name='PlatformStaffInvitation',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email', models.EmailField(help_text='Email address to send invitation to', max_length=254)),
+ ('role', models.CharField(choices=[('platform_manager', 'Platform Manager'), ('platform_support', 'Platform Support')], default='platform_support', help_text='Role the invited user will have', max_length=20)),
+ ('token', models.CharField(max_length=64, unique=True)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('expires_at', models.DateTimeField()),
+ ('accepted_at', models.DateTimeField(blank=True, null=True)),
+ ('permissions', models.JSONField(blank=True, default=dict, help_text='Platform permission settings for the invited user')),
+ ('personal_message', models.TextField(blank=True, help_text='Optional personal message to include in the invitation email')),
+ ('accepted_user', models.ForeignKey(blank=True, help_text='User account created/activated when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_staff_invitation_accepted', to=settings.AUTH_USER_MODEL)),
+ ('invited_by', models.ForeignKey(help_text='User who sent the invitation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_staff_invitations_sent', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['token'], name='platform_ad_token_e94488_idx'), models.Index(fields=['email', 'status'], name='platform_ad_email_f8e580_idx'), models.Index(fields=['status', 'expires_at'], name='platform_ad_status_276620_idx')],
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/platform/admin/models.py b/smoothschedule/smoothschedule/platform/admin/models.py
index ba26478b..e4a5e312 100644
--- a/smoothschedule/smoothschedule/platform/admin/models.py
+++ b/smoothschedule/smoothschedule/platform/admin/models.py
@@ -723,3 +723,383 @@ class PlatformEmailAddress(models.Model):
'username': self.email_address,
'password': self.password,
}
+
+
+class PlatformEmailType(models.TextChoices):
+ """
+ Email types for platform-level communications.
+ These are sent from the platform to tenants/owners, not tenant-to-customer.
+ """
+ TENANT_INVITATION = 'tenant_invitation', _('Tenant Invitation')
+ TRIAL_EXPIRATION_WARNING = 'trial_expiration_warning', _('Trial Expiration Warning')
+ TRIAL_EXPIRED = 'trial_expired', _('Trial Expired')
+ PLAN_UPGRADE = 'plan_upgrade', _('Plan Upgrade Confirmation')
+ PLAN_DOWNGRADE = 'plan_downgrade', _('Plan Downgrade Confirmation')
+ SUBSCRIPTION_CANCELLED = 'subscription_cancelled', _('Subscription Cancelled')
+ PAYMENT_FAILED = 'payment_failed', _('Payment Failed')
+ PAYMENT_SUCCEEDED = 'payment_succeeded', _('Payment Succeeded')
+ # Staff onboarding emails
+ PLATFORM_STAFF_INVITATION = 'platform_staff_invitation', _('Platform Staff Invitation')
+ PLATFORM_STAFF_WELCOME = 'platform_staff_welcome', _('Platform Staff Welcome')
+
+ @classmethod
+ def get_display_name(cls, email_type: str) -> str:
+ """Get human-readable display name for an email type."""
+ display_names = {
+ cls.TENANT_INVITATION: 'Tenant Invitation',
+ cls.TRIAL_EXPIRATION_WARNING: 'Trial Expiration Warning',
+ cls.TRIAL_EXPIRED: 'Trial Expired',
+ cls.PLAN_UPGRADE: 'Plan Upgrade Confirmation',
+ cls.PLAN_DOWNGRADE: 'Plan Downgrade Confirmation',
+ cls.SUBSCRIPTION_CANCELLED: 'Subscription Cancelled',
+ cls.PAYMENT_FAILED: 'Payment Failed',
+ cls.PAYMENT_SUCCEEDED: 'Payment Succeeded',
+ cls.PLATFORM_STAFF_INVITATION: 'Platform Staff Invitation',
+ cls.PLATFORM_STAFF_WELCOME: 'Platform Staff Welcome',
+ }
+ return display_names.get(email_type, email_type.replace('_', ' ').title())
+
+ @classmethod
+ def get_description(cls, email_type: str) -> str:
+ """Get description of when this email type is sent."""
+ descriptions = {
+ cls.TENANT_INVITATION: 'Sent when inviting a new business owner to create their tenant on the platform.',
+ cls.TRIAL_EXPIRATION_WARNING: 'Sent a few days before a trial period expires to encourage subscription.',
+ cls.TRIAL_EXPIRED: 'Sent when a trial period has expired and the business needs to subscribe.',
+ cls.PLAN_UPGRADE: 'Sent when a business successfully upgrades their subscription plan.',
+ cls.PLAN_DOWNGRADE: 'Sent when a business downgrades their subscription plan.',
+ cls.SUBSCRIPTION_CANCELLED: 'Sent when a business cancels their subscription.',
+ cls.PAYMENT_FAILED: 'Sent when a subscription payment fails and action is needed.',
+ cls.PAYMENT_SUCCEEDED: 'Sent as a receipt after a successful subscription payment.',
+ cls.PLATFORM_STAFF_INVITATION: 'Sent when inviting a new platform staff member (manager or support).',
+ cls.PLATFORM_STAFF_WELCOME: 'Sent after a platform staff member completes their account setup.',
+ }
+ return descriptions.get(email_type, '')
+
+ @classmethod
+ def get_category(cls, email_type: str) -> str:
+ """Get the category for an email type."""
+ categories = {
+ cls.TENANT_INVITATION: 'invitation',
+ cls.TRIAL_EXPIRATION_WARNING: 'trial',
+ cls.TRIAL_EXPIRED: 'trial',
+ cls.PLAN_UPGRADE: 'subscription',
+ cls.PLAN_DOWNGRADE: 'subscription',
+ cls.SUBSCRIPTION_CANCELLED: 'subscription',
+ cls.PAYMENT_FAILED: 'billing',
+ cls.PAYMENT_SUCCEEDED: 'billing',
+ cls.PLATFORM_STAFF_INVITATION: 'staff',
+ cls.PLATFORM_STAFF_WELCOME: 'staff',
+ }
+ return categories.get(email_type, 'other')
+
+
+class PlatformEmailTemplate(models.Model):
+ """
+ Platform-level email templates using the Puck visual editor.
+
+ These templates are for emails sent from the platform to tenant owners,
+ such as invitations, billing notifications, and subscription updates.
+
+ Unlike PuckEmailTemplate which is tenant-scoped, this model lives in the
+ public schema and has only one template per email type globally.
+ """
+
+ email_type = models.CharField(
+ max_length=50,
+ choices=PlatformEmailType.choices,
+ unique=True,
+ help_text="The type of platform email this template is for"
+ )
+
+ subject_template = models.CharField(
+ max_length=500,
+ help_text="Email subject line with {{ tag }} placeholders"
+ )
+
+ puck_data = models.JSONField(
+ default=dict,
+ help_text="Puck editor JSON data for the email body"
+ )
+
+ is_active = models.BooleanField(
+ default=True,
+ help_text="Whether this template is currently in use"
+ )
+
+ # Track customization
+ is_customized = models.BooleanField(
+ default=False,
+ help_text="Whether this template has been customized from the default"
+ )
+
+ # Metadata
+ created_by = models.ForeignKey(
+ 'users.User',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='platform_email_templates_created',
+ help_text="User who created/last modified this template"
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ app_label = 'platform_admin'
+ ordering = ['email_type']
+ verbose_name = 'Platform Email Template'
+ verbose_name_plural = 'Platform Email Templates'
+
+ def __str__(self):
+ return f"Platform Template: {self.get_email_type_display()}"
+
+ @property
+ def display_name(self) -> str:
+ """Get human-readable display name."""
+ return PlatformEmailType.get_display_name(self.email_type)
+
+ @property
+ def description(self) -> str:
+ """Get description of when this email is sent."""
+ return PlatformEmailType.get_description(self.email_type)
+
+ @property
+ def category(self) -> str:
+ """Get the category for this email type."""
+ return PlatformEmailType.get_category(self.email_type)
+
+ def reset_to_default(self):
+ """Reset this template to its default content."""
+ from .platform_email_templates import get_default_platform_template
+
+ default = get_default_platform_template(self.email_type)
+ self.subject_template = default['subject_template']
+ self.puck_data = default['puck_data']
+ self.is_customized = False
+ self.save()
+
+ def render(self, context: dict) -> dict:
+ """
+ Render the template with the given context.
+
+ Args:
+ context: Dictionary of tag values to substitute
+
+ Returns:
+ Dict with 'subject', 'html', and 'text' keys
+ """
+ from smoothschedule.communication.messaging.email_renderer import (
+ render_subject,
+ render_email_html,
+ render_email_plaintext,
+ )
+
+ return {
+ 'subject': render_subject(self.subject_template, context),
+ 'html': render_email_html(self.puck_data, context),
+ 'text': render_email_plaintext(self.puck_data, context),
+ }
+
+ @classmethod
+ def get_or_create_for_type(cls, email_type: str) -> 'PlatformEmailTemplate':
+ """
+ Get the template for an email type, creating from defaults if needed.
+
+ Args:
+ email_type: The PlatformEmailType value
+
+ Returns:
+ PlatformEmailTemplate instance
+ """
+ from .platform_email_templates import get_default_platform_template
+
+ template, created = cls.objects.get_or_create(
+ email_type=email_type,
+ defaults={
+ **get_default_platform_template(email_type),
+ 'is_customized': False,
+ }
+ )
+ return template
+
+ @classmethod
+ def ensure_all_templates_exist(cls):
+ """
+ Ensure templates exist for all platform email types.
+ Creates any missing templates from defaults.
+ """
+ from .platform_email_templates import get_default_platform_template
+
+ for email_type in PlatformEmailType.values:
+ cls.objects.get_or_create(
+ email_type=email_type,
+ defaults={
+ **get_default_platform_template(email_type),
+ 'is_customized': False,
+ }
+ )
+
+
+class PlatformStaffInvitation(models.Model):
+ """
+ Invitation for new platform staff members (platform_manager, platform_support).
+
+ Flow:
+ 1. Superuser creates invitation with email and role
+ 2. System sends email with unique token link
+ 3. Invitee clicks link, sets password, and their account is activated
+ """
+
+ class Status(models.TextChoices):
+ PENDING = 'PENDING', _('Pending')
+ ACCEPTED = 'ACCEPTED', _('Accepted')
+ EXPIRED = 'EXPIRED', _('Expired')
+ CANCELLED = 'CANCELLED', _('Cancelled')
+
+ class StaffRole(models.TextChoices):
+ PLATFORM_MANAGER = 'platform_manager', _('Platform Manager')
+ PLATFORM_SUPPORT = 'platform_support', _('Platform Support')
+
+ # Invitation target
+ email = models.EmailField(help_text="Email address to send invitation to")
+ role = models.CharField(
+ max_length=20,
+ choices=StaffRole.choices,
+ default=StaffRole.PLATFORM_SUPPORT,
+ help_text="Role the invited user will have"
+ )
+
+ # Invitation metadata
+ invited_by = models.ForeignKey(
+ 'users.User',
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name='platform_staff_invitations_sent',
+ help_text="User who sent the invitation"
+ )
+
+ # Token for secure acceptance
+ token = models.CharField(max_length=64, unique=True)
+
+ # Status tracking
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.PENDING
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+ expires_at = models.DateTimeField()
+ accepted_at = models.DateTimeField(null=True, blank=True)
+
+ # Link to created user (after acceptance)
+ accepted_user = models.ForeignKey(
+ 'users.User',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='platform_staff_invitation_accepted',
+ help_text="User account created/activated when invitation was accepted"
+ )
+
+ # Permissions configuration (stored as JSON for flexibility)
+ # Currently unused but kept for future extensibility
+ permissions = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Platform permission settings for the invited user"
+ )
+
+ # Personal message to include in email
+ personal_message = models.TextField(
+ blank=True,
+ help_text="Optional personal message to include in the invitation email"
+ )
+
+ class Meta:
+ app_label = 'platform_admin'
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['token']),
+ models.Index(fields=['email', 'status']),
+ models.Index(fields=['status', 'expires_at']),
+ ]
+
+ def __str__(self):
+ return f"Platform staff invitation for {self.email} ({self.get_status_display()})"
+
+ def save(self, *args, **kwargs):
+ if not self.token:
+ self.token = secrets.token_urlsafe(32)
+ if not self.expires_at:
+ # Default expiration: 7 days
+ self.expires_at = timezone.now() + timedelta(days=7)
+ super().save(*args, **kwargs)
+
+ def is_valid(self):
+ """Check if invitation can still be accepted"""
+ if self.status != self.Status.PENDING:
+ return False
+ if timezone.now() > self.expires_at:
+ return False
+ return True
+
+ def accept(self, user):
+ """Mark invitation as accepted and link to user"""
+ self.status = self.Status.ACCEPTED
+ self.accepted_at = timezone.now()
+ self.accepted_user = user
+ self.save()
+
+ def cancel(self):
+ """Cancel a pending invitation"""
+ if self.status == self.Status.PENDING:
+ self.status = self.Status.CANCELLED
+ self.save()
+
+ def get_role_display_name(self):
+ """Get human-readable role name"""
+ role_names = {
+ self.StaffRole.PLATFORM_MANAGER: 'Platform Manager',
+ self.StaffRole.PLATFORM_SUPPORT: 'Platform Support',
+ }
+ return role_names.get(self.role, self.role)
+
+ def get_role_description(self):
+ """Get role description for email templates"""
+ descriptions = {
+ self.StaffRole.PLATFORM_MANAGER: (
+ 'Platform Managers have access to manage tenants, '
+ 'view analytics, and oversee platform operations.'
+ ),
+ self.StaffRole.PLATFORM_SUPPORT: (
+ 'Platform Support staff can respond to support tickets, '
+ 'help users troubleshoot issues, and access basic tenant information.'
+ ),
+ }
+ return descriptions.get(self.role, '')
+
+ @classmethod
+ def create_invitation(cls, email, role, invited_by, permissions=None,
+ personal_message=''):
+ """
+ Create a new platform staff invitation, cancelling any existing
+ pending invitations for the same email.
+ """
+ # Cancel existing pending invitations for this email
+ cls.objects.filter(
+ email=email,
+ status=cls.Status.PENDING
+ ).update(status=cls.Status.CANCELLED)
+
+ # Create new invitation
+ return cls.objects.create(
+ email=email,
+ role=role,
+ invited_by=invited_by,
+ permissions=permissions or {},
+ personal_message=personal_message,
+ )
diff --git a/smoothschedule/smoothschedule/platform/admin/platform_email_templates.py b/smoothschedule/smoothschedule/platform/admin/platform_email_templates.py
new file mode 100644
index 00000000..f02a2950
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/admin/platform_email_templates.py
@@ -0,0 +1,820 @@
+"""
+Platform Email Templates
+
+Default Puck templates for platform-level email communications.
+These are used for emails sent from the platform to tenant owners,
+such as invitations, billing notifications, and subscription updates.
+"""
+
+# =============================================================================
+# Platform-Specific Tags
+# =============================================================================
+
+PLATFORM_TAGS = {
+ # Platform info
+ 'platform_name': 'Platform name (SmoothSchedule)',
+ 'platform_url': 'Platform website URL',
+ 'platform_support_email': 'Platform support email address',
+
+ # Tenant/Business info
+ 'tenant_name': 'Business/tenant name',
+ 'tenant_subdomain': 'Business subdomain',
+ 'owner_name': 'Business owner full name',
+ 'owner_first_name': 'Business owner first name',
+ 'owner_email': 'Business owner email address',
+
+ # Invitation-specific
+ 'inviter_name': 'Name of person who sent the invitation',
+ 'invitation_link': 'Link to accept the invitation',
+ 'invitation_expires_at': 'When the invitation expires',
+ 'personal_message': 'Personal message from inviter',
+ 'suggested_business_name': 'Suggested business name',
+
+ # Staff-specific
+ 'staff_name': 'Staff member full name',
+ 'staff_first_name': 'Staff member first name',
+ 'staff_email': 'Staff member email address',
+ 'staff_role': 'Staff role (Platform Manager or Platform Support)',
+ 'staff_role_description': 'Description of the staff role and permissions',
+ 'login_link': 'Link to platform login page',
+ 'set_password_link': 'Link for staff to set their password',
+
+ # Trial-specific
+ 'trial_days_remaining': 'Days remaining in trial',
+ 'trial_end_date': 'When the trial ends',
+
+ # Subscription/Plan info
+ 'plan_name': 'Subscription plan name',
+ 'plan_price': 'Plan price (formatted)',
+ 'old_plan_name': 'Previous plan name (for changes)',
+ 'new_plan_name': 'New plan name (for changes)',
+
+ # Billing/Payment info
+ 'payment_amount': 'Payment amount (formatted)',
+ 'payment_date': 'Payment date',
+ 'invoice_link': 'Link to view invoice',
+ 'invoice_number': 'Invoice number',
+ 'next_billing_date': 'Next billing date',
+ 'card_last_four': 'Last 4 digits of payment card',
+ 'failure_reason': 'Reason for payment failure',
+ 'update_payment_link': 'Link to update payment method',
+
+ # Date/time
+ 'current_date': 'Current date',
+ 'current_year': 'Current year',
+}
+
+
+def get_platform_tags():
+ """Get all platform tags with descriptions."""
+ return [
+ {'name': name, 'description': desc, 'category': _get_tag_category(name)}
+ for name, desc in PLATFORM_TAGS.items()
+ ]
+
+
+def _get_tag_category(tag_name: str) -> str:
+ """Get category for a tag."""
+ if tag_name.startswith('platform_'):
+ return 'Platform'
+ elif tag_name.startswith('tenant_') or tag_name.startswith('owner_'):
+ return 'Business'
+ elif tag_name.startswith('invitation_') or tag_name in ('inviter_name', 'personal_message', 'suggested_business_name'):
+ return 'Invitation'
+ elif tag_name.startswith('staff_') or tag_name in ('login_link', 'set_password_link'):
+ return 'Staff'
+ elif tag_name.startswith('trial_'):
+ return 'Trial'
+ elif tag_name.startswith('plan_') or tag_name in ('old_plan_name', 'new_plan_name'):
+ return 'Subscription'
+ elif tag_name.startswith('payment_') or tag_name.startswith('invoice_') or tag_name in ('card_last_four', 'failure_reason', 'update_payment_link', 'next_billing_date'):
+ return 'Billing'
+ else:
+ return 'Other'
+
+
+# =============================================================================
+# Default Templates
+# =============================================================================
+
+DEFAULT_PLATFORM_TEMPLATES = {
+ # =========================================================================
+ # Tenant Invitation
+ # =========================================================================
+ 'tenant_invitation': {
+ 'subject_template': "You're Invited to {{ platform_name }}!",
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': '{{ inviter_name }} has invited you to create your business'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': "You're Invited!",
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi there,\n\n{{ inviter_name }} has invited you to create your business on {{ platform_name }}, the modern scheduling platform that helps you manage appointments, staff, and customers effortlessly.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Suggested Business Name: {{ suggested_business_name }}',
+ 'backgroundColor': '#f3f4f6'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Accept Invitation & Get Started',
+ 'href': '{{ invitation_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'This invitation expires on {{ invitation_expires_at }}.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Trial Expiration Warning
+ # =========================================================================
+ 'trial_expiration_warning': {
+ 'subject_template': 'Your {{ platform_name }} trial expires in {{ trial_days_remaining }} days',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Your trial is ending soon - subscribe to keep your data'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Your Trial is Ending Soon',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nYour free trial for {{ tenant_name }} expires in {{ trial_days_remaining }} days on {{ trial_end_date }}.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'What happens when your trial ends?
• You will lose access to your account
• Your data will be preserved for 30 days
• Subscribe anytime to restore full access',
+ 'backgroundColor': '#fef3c7'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "Subscribe now to ensure uninterrupted service and keep all your appointments, customers, and settings.",
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Subscribe Now',
+ 'href': '{{ platform_url }}/dashboard/settings/billing',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Trial Expired
+ # =========================================================================
+ 'trial_expired': {
+ 'subject_template': 'Your {{ platform_name }} trial has expired',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Subscribe now to restore access to your account'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Your Trial Has Expired',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nYour free trial for {{ tenant_name }} has expired as of {{ trial_end_date }}.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': "Don't worry - your data is safe!
We'll keep your data for 30 days. Subscribe anytime within this period to restore full access to your account.",
+ 'backgroundColor': '#fee2e2'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Subscribe & Restore Access',
+ 'href': '{{ platform_url }}/dashboard/settings/billing',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Have questions? Reply to this email or contact our support team.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Plan Upgrade Confirmation
+ # =========================================================================
+ 'plan_upgrade': {
+ 'subject_template': 'Welcome to {{ new_plan_name }}! Your upgrade is complete',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Your plan upgrade is complete'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Upgrade Complete!',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nGreat news! Your {{ tenant_name }} account has been upgraded to the {{ new_plan_name }} plan.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Plan Details:
• New Plan: {{ new_plan_name }}
• Price: {{ plan_price }}/month
• Next Billing Date: {{ next_billing_date }}',
+ 'backgroundColor': '#d1fae5'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Your new features are now available. Explore your upgraded account!',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Go to Dashboard',
+ 'href': '{{ platform_url }}/dashboard',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Plan Downgrade Confirmation
+ # =========================================================================
+ 'plan_downgrade': {
+ 'subject_template': 'Your plan change to {{ new_plan_name }} is confirmed',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Your plan change has been processed'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Plan Change Confirmed',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nYour {{ tenant_name }} account has been changed from {{ old_plan_name }} to {{ new_plan_name }}.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Plan Details:
• New Plan: {{ new_plan_name }}
• Price: {{ plan_price }}/month
• Effective Date: {{ next_billing_date }}',
+ 'backgroundColor': '#dbeafe'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Some features may no longer be available on your new plan. If you have any questions, please contact our support team.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'View Plan Details',
+ 'href': '{{ platform_url }}/dashboard/settings/billing',
+ 'variant': 'secondary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Subscription Cancelled
+ # =========================================================================
+ 'subscription_cancelled': {
+ 'subject_template': 'Your {{ platform_name }} subscription has been cancelled',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Your subscription cancellation is confirmed'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Subscription Cancelled',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "Hi {{ owner_first_name }},\n\nWe're sorry to see you go. Your {{ tenant_name }} subscription has been cancelled.",
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': "What happens next?
• Your account will remain active until {{ next_billing_date }}
• After that, you'll lose access to your account
• Your data will be preserved for 30 days
• You can resubscribe anytime to restore access",
+ 'backgroundColor': '#f3f4f6'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "Changed your mind? You can reactivate your subscription anytime before the end of your billing period.",
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Reactivate Subscription',
+ 'href': '{{ platform_url }}/dashboard/settings/billing',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "We'd love to hear your feedback. Reply to this email to let us know how we could improve.",
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Payment Failed
+ # =========================================================================
+ 'payment_failed': {
+ 'subject_template': 'Action Required: Payment failed for {{ tenant_name }}',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Please update your payment method'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Payment Failed',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nWe were unable to process your payment for {{ tenant_name }}.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Payment Details:
• Amount: {{ payment_amount }}
• Card ending in: {{ card_last_four }}
• Reason: {{ failure_reason }}',
+ 'backgroundColor': '#fee2e2'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Please update your payment method to avoid service interruption. We will automatically retry the payment once your card is updated.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Update Payment Method',
+ 'href': '{{ update_payment_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'If you need assistance, please contact our support team.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Payment Succeeded
+ # =========================================================================
+ 'payment_succeeded': {
+ 'subject_template': 'Payment Receipt - {{ platform_name }}',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Thank you for your payment'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Payment Received',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ owner_first_name }},\n\nThank you for your payment. Here is your receipt:',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Payment Details:
• Amount: {{ payment_amount }}
• Date: {{ payment_date }}
• Invoice: {{ invoice_number }}
• Plan: {{ plan_name }}
• Next Billing Date: {{ next_billing_date }}',
+ 'backgroundColor': '#d1fae5'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'View Invoice',
+ 'href': '{{ invoice_link }}',
+ 'variant': 'secondary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Thank you for being a {{ platform_name }} customer!',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Platform Staff Invitation
+ # =========================================================================
+ 'platform_staff_invitation': {
+ 'subject_template': "You're Invited to Join the {{ platform_name }} Team!",
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': '{{ inviter_name }} has invited you to join as {{ staff_role }}'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': "You're Invited to Join Our Team!",
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi there,\n\n{{ inviter_name }} has invited you to join the {{ platform_name }} team as a {{ staff_role }}.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Your Role: {{ staff_role }}
{{ staff_role_description }}',
+ 'backgroundColor': '#ede9fe'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Accept Invitation & Set Password',
+ 'href': '{{ set_password_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'This invitation expires on {{ invitation_expires_at }}.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'If you have any questions, please contact {{ inviter_name }} or reply to this email.',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+
+ # =========================================================================
+ # Platform Staff Welcome
+ # =========================================================================
+ 'platform_staff_welcome': {
+ 'subject_template': 'Welcome to the {{ platform_name }} Team, {{ staff_first_name }}!',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {
+ 'businessName': '{{ platform_name }}',
+ 'preheader': 'Your account is ready - get started today!'
+ }
+ },
+ {
+ 'type': 'EmailHeading',
+ 'props': {
+ 'text': 'Welcome to the Team!',
+ 'level': 'h1',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'Hi {{ staff_first_name }},\n\nWelcome to the {{ platform_name }} team! Your account is now active and you can start working right away.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailPanel',
+ 'props': {
+ 'content': 'Your Account Details:
• Email: {{ staff_email }}
• Role: {{ staff_role }}',
+ 'backgroundColor': '#d1fae5'
+ }
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': 'As a {{ staff_role }}, you have access to the platform dashboard where you can help manage tenants, support users, and more.',
+ 'align': 'left'
+ }
+ },
+ {
+ 'type': 'EmailButton',
+ 'props': {
+ 'text': 'Go to Platform Dashboard',
+ 'href': '{{ login_link }}',
+ 'variant': 'primary',
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailSpacer',
+ 'props': {'size': 'md'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {
+ 'content': "If you need any help getting started, don't hesitate to reach out to the team.",
+ 'align': 'center'
+ }
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ },
+}
+
+
+def get_default_platform_template(email_type: str) -> dict:
+ """
+ Get the default template for a platform email type.
+
+ Args:
+ email_type: The PlatformEmailType value
+
+ Returns:
+ Dict with 'subject_template' and 'puck_data' keys
+ """
+ return DEFAULT_PLATFORM_TEMPLATES.get(email_type, {
+ 'subject_template': f'{email_type.replace("_", " ").title()} Email',
+ 'puck_data': {
+ 'content': [
+ {
+ 'type': 'EmailHeader',
+ 'props': {'businessName': '{{ platform_name }}'}
+ },
+ {
+ 'type': 'EmailText',
+ 'props': {'content': 'Hello {{ owner_first_name }},'}
+ },
+ {
+ 'type': 'EmailFooter',
+ 'props': {
+ 'email': '{{ platform_support_email }}'
+ }
+ }
+ ],
+ 'root': {}
+ }
+ })
diff --git a/smoothschedule/smoothschedule/platform/admin/serializers.py b/smoothschedule/smoothschedule/platform/admin/serializers.py
index 49d2ecfd..167d47f5 100644
--- a/smoothschedule/smoothschedule/platform/admin/serializers.py
+++ b/smoothschedule/smoothschedule/platform/admin/serializers.py
@@ -5,7 +5,11 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
from rest_framework import serializers
from smoothschedule.identity.core.models import Tenant, Domain
from smoothschedule.identity.users.models import User
-from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
+from .models import (
+ TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress,
+ PlatformEmailTemplate, PlatformEmailType,
+)
+from .platform_email_templates import get_platform_tags
class PlatformSettingsSerializer(serializers.Serializer):
@@ -881,3 +885,109 @@ class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
return instance
+
+# =============================================================================
+# Platform Email Template Serializers
+# =============================================================================
+
+class PlatformEmailTemplateListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for listing platform email templates."""
+ display_name = serializers.SerializerMethodField()
+ description = serializers.SerializerMethodField()
+ category = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PlatformEmailTemplate
+ fields = [
+ 'email_type', 'subject_template', 'is_active', 'is_customized',
+ 'display_name', 'description', 'category',
+ 'created_at', 'updated_at',
+ ]
+ read_only_fields = fields
+
+ def get_display_name(self, obj):
+ return PlatformEmailType.get_display_name(obj.email_type)
+
+ def get_description(self, obj):
+ return PlatformEmailType.get_description(obj.email_type)
+
+ def get_category(self, obj):
+ return PlatformEmailType.get_category(obj.email_type)
+
+
+class PlatformEmailTemplateDetailSerializer(serializers.ModelSerializer):
+ """Full serializer for platform email template with available tags."""
+ display_name = serializers.SerializerMethodField()
+ description = serializers.SerializerMethodField()
+ category = serializers.SerializerMethodField()
+ available_tags = serializers.SerializerMethodField()
+ created_by_email = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PlatformEmailTemplate
+ fields = [
+ 'email_type', 'subject_template', 'puck_data',
+ 'is_active', 'is_customized',
+ 'display_name', 'description', 'category', 'available_tags',
+ 'created_by', 'created_by_email',
+ 'created_at', 'updated_at',
+ ]
+ read_only_fields = [
+ 'email_type', 'is_customized',
+ 'display_name', 'description', 'category', 'available_tags',
+ 'created_by', 'created_by_email',
+ 'created_at', 'updated_at',
+ ]
+
+ def get_display_name(self, obj):
+ return PlatformEmailType.get_display_name(obj.email_type)
+
+ def get_description(self, obj):
+ return PlatformEmailType.get_description(obj.email_type)
+
+ def get_category(self, obj):
+ return PlatformEmailType.get_category(obj.email_type)
+
+ def get_available_tags(self, obj):
+ """Return available template tags for this email type."""
+ # get_platform_tags() already returns a list of dicts with name, description, category
+ tags = get_platform_tags()
+ # Add syntax field to each tag
+ return [
+ {
+ 'name': tag['name'],
+ 'description': tag['description'],
+ 'syntax': f'{{{{ {tag["name"]} }}}}',
+ }
+ for tag in tags
+ ]
+
+ def get_created_by_email(self, obj):
+ if obj.created_by:
+ return obj.created_by.email
+ return None
+
+ def update(self, instance, validated_data):
+ """Mark template as customized when updated."""
+ instance = super().update(instance, validated_data)
+ if not instance.is_customized:
+ instance.is_customized = True
+ instance.save(update_fields=['is_customized'])
+ return instance
+
+
+class PlatformEmailTemplatePreviewSerializer(serializers.Serializer):
+ """Serializer for previewing rendered platform email templates."""
+ context = serializers.DictField(
+ required=False,
+ default=dict,
+ help_text="Optional context variables for rendering the template"
+ )
+
+ def validate_context(self, value):
+ """Ensure all context values are strings."""
+ for key, val in value.items():
+ if not isinstance(val, str):
+ value[key] = str(val)
+ return value
+
diff --git a/smoothschedule/smoothschedule/platform/admin/tasks.py b/smoothschedule/smoothschedule/platform/admin/tasks.py
index ce05317c..5804adf3 100644
--- a/smoothschedule/smoothschedule/platform/admin/tasks.py
+++ b/smoothschedule/smoothschedule/platform/admin/tasks.py
@@ -470,3 +470,200 @@ def sync_staff_email_folder(email_address_id: int, folder_name: str = 'INBOX'):
exc_info=True
)
return {'success': False, 'error': str(e)}
+
+
+# ============================================================================
+# Platform Staff Invitation Tasks
+# ============================================================================
+
+@shared_task(bind=True, max_retries=3, name='platform.send_platform_staff_invitation_email')
+def send_platform_staff_invitation_email(self, invitation_id: int):
+ """
+ Send an invitation email to a prospective platform staff member.
+
+ Args:
+ invitation_id: ID of the PlatformStaffInvitation to send
+ """
+ from .models import PlatformStaffInvitation, PlatformEmailTemplate, PlatformEmailType
+
+ try:
+ invitation = PlatformStaffInvitation.objects.select_related('invited_by').get(id=invitation_id)
+ except PlatformStaffInvitation.DoesNotExist:
+ logger.error(f"PlatformStaffInvitation {invitation_id} not found")
+ return {'success': False, 'error': 'Invitation not found'}
+
+ # Don't send if not pending
+ if invitation.status != PlatformStaffInvitation.Status.PENDING:
+ logger.info(f"Skipping email for platform staff invitation {invitation_id} - status is {invitation.status}")
+ return {'success': False, 'error': f'Invitation status is {invitation.status}'}
+
+ if not invitation.is_valid():
+ logger.info(f"Skipping email for platform staff invitation {invitation_id} - invitation expired")
+ return {'success': False, 'error': 'Invitation expired'}
+
+ try:
+ # Build the invitation URL
+ base_url = get_base_url()
+ set_password_url = f"{base_url}/platform-staff-invite?token={invitation.token}"
+
+ # Get the email template
+ template = PlatformEmailTemplate.get_or_create_for_type(
+ PlatformEmailType.PLATFORM_STAFF_INVITATION
+ )
+
+ # Build context for template
+ inviter_name = invitation.invited_by.get_full_name() or invitation.invited_by.email if invitation.invited_by else 'SmoothSchedule Team'
+ expires_at_str = invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') if invitation.expires_at else 'in 7 days'
+
+ context = {
+ 'platform_name': 'SmoothSchedule',
+ 'platform_url': base_url,
+ 'platform_support_email': 'support@smoothschedule.com',
+ 'inviter_name': inviter_name,
+ 'staff_role': invitation.get_role_display_name(),
+ 'staff_role_description': invitation.get_role_description(),
+ 'set_password_link': set_password_url,
+ 'invitation_expires_at': expires_at_str,
+ 'personal_message': invitation.personal_message or '',
+ }
+
+ # Render the template
+ rendered = template.render(context)
+
+ # Build plain text version with the link explicitly included
+ plain_text_lines = [
+ f"You're Invited to Join SmoothSchedule as {invitation.get_role_display_name()}",
+ "",
+ f"{inviter_name} has invited you to join the SmoothSchedule platform team.",
+ "",
+ f"Your Role: {invitation.get_role_display_name()}",
+ f"{invitation.get_role_description()}",
+ "",
+ ]
+
+ if invitation.personal_message:
+ plain_text_lines.extend([
+ "Personal Message:",
+ f'"{invitation.personal_message}"',
+ "",
+ ])
+
+ plain_text_lines.extend([
+ "To accept this invitation and set up your account, visit:",
+ set_password_url,
+ "",
+ f"This invitation expires on {expires_at_str}.",
+ "",
+ "If you have any questions, please contact support@smoothschedule.com.",
+ "",
+ "---",
+ "SmoothSchedule Platform Team",
+ ])
+
+ plain_text_body = "\n".join(plain_text_lines)
+
+ # Send email
+ from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
+
+ email = EmailMultiAlternatives(
+ subject=rendered['subject'],
+ body=plain_text_body,
+ from_email=from_email,
+ to=[invitation.email],
+ )
+ email.attach_alternative(rendered['html'], "text/html")
+ email.send()
+
+ logger.info(f"Sent platform staff invitation email to {invitation.email}")
+ return {'success': True, 'email': invitation.email}
+
+ except Exception as e:
+ logger.error(f"Failed to send platform staff invitation email for {invitation_id}: {e}", exc_info=True)
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
+
+
+@shared_task(bind=True, max_retries=3, name='platform.send_platform_staff_welcome_email')
+def send_platform_staff_welcome_email(self, user_id: int, role: str):
+ """
+ Send a welcome email to a newly onboarded platform staff member.
+
+ Args:
+ user_id: ID of the User who just joined
+ role: The role they were assigned
+ """
+ from smoothschedule.identity.users.models import User
+ from .models import PlatformEmailTemplate, PlatformEmailType, PlatformStaffInvitation
+
+ try:
+ user = User.objects.get(id=user_id)
+ except User.DoesNotExist:
+ logger.error(f"User {user_id} not found for welcome email")
+ return {'success': False, 'error': 'User not found'}
+
+ try:
+ # Get the email template
+ template = PlatformEmailTemplate.get_or_create_for_type(
+ PlatformEmailType.PLATFORM_STAFF_WELCOME
+ )
+
+ # Get role display name
+ role_names = {
+ PlatformStaffInvitation.StaffRole.PLATFORM_MANAGER: 'Platform Manager',
+ PlatformStaffInvitation.StaffRole.PLATFORM_SUPPORT: 'Platform Support',
+ }
+ role_display = role_names.get(role, role)
+
+ # Build context for template
+ base_url = get_base_url()
+ staff_name = user.get_full_name() or user.email
+ staff_first_name = user.first_name or 'there'
+
+ context = {
+ 'platform_name': 'SmoothSchedule',
+ 'platform_url': base_url,
+ 'platform_support_email': 'support@smoothschedule.com',
+ 'staff_name': staff_name,
+ 'staff_first_name': staff_first_name,
+ 'staff_email': user.email,
+ 'staff_role': role_display,
+ 'login_link': base_url,
+ }
+
+ # Render the template
+ rendered = template.render(context)
+
+ # Build plain text version
+ plain_text_lines = [
+ f"Welcome to SmoothSchedule, {staff_first_name}!",
+ "",
+ f"Your account has been created as a {role_display}.",
+ "",
+ "You can now log in to the platform dashboard at:",
+ base_url,
+ "",
+ "If you have any questions, please contact support@smoothschedule.com.",
+ "",
+ "---",
+ "SmoothSchedule Platform Team",
+ ]
+
+ plain_text_body = "\n".join(plain_text_lines)
+
+ # Send email
+ from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
+
+ email = EmailMultiAlternatives(
+ subject=rendered['subject'],
+ body=plain_text_body,
+ from_email=from_email,
+ to=[user.email],
+ )
+ email.attach_alternative(rendered['html'], "text/html")
+ email.send()
+
+ logger.info(f"Sent platform staff welcome email to {user.email}")
+ return {'success': True, 'email': user.email}
+
+ except Exception as e:
+ logger.error(f"Failed to send platform staff welcome email for user {user_id}: {e}", exc_info=True)
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
diff --git a/smoothschedule/smoothschedule/platform/admin/tests/test_views.py b/smoothschedule/smoothschedule/platform/admin/tests/test_views.py
index c9e2505a..561ac2bf 100644
--- a/smoothschedule/smoothschedule/platform/admin/tests/test_views.py
+++ b/smoothschedule/smoothschedule/platform/admin/tests/test_views.py
@@ -1785,46 +1785,6 @@ class TestPlatformUserViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert 'Invalid role' in response.data['detail']
- def test_partial_update_merges_permissions(self):
- """Test partial_update merges permissions"""
- request = self.factory.patch('/api/platform/users/1/', {
- 'permissions': {
- 'can_approve_plugins': True,
- 'can_whitelist_urls': True
- }
- }, format='json')
- request.user = Mock(
- is_authenticated=True,
- role=User.Role.SUPERUSER,
- permissions={'can_approve_plugins': True, 'can_whitelist_urls': True}
- )
- # Add .data attribute for DRF compatibility
- request.data = {
- 'permissions': {
- 'can_approve_plugins': True,
- 'can_whitelist_urls': True
- }
- }
-
- mock_user = Mock(
- role=User.Role.PLATFORM_MANAGER,
- permissions={'existing_perm': True}
- )
-
- mock_serializer = Mock()
- mock_serializer.data = {'id': 1}
-
- view = self.viewset()
- view.request = request
- view.get_object = Mock(return_value=mock_user)
- view.get_serializer = Mock(return_value=mock_serializer)
- response = view.partial_update(request)
-
- assert response.status_code == status.HTTP_200_OK
- assert mock_user.permissions['can_approve_plugins'] is True
- assert mock_user.permissions['can_whitelist_urls'] is True
- assert mock_user.permissions['existing_perm'] is True
-
def test_partial_update_sets_password(self):
"""Test partial_update can set password"""
request = self.factory.patch('/api/platform/users/1/', {
diff --git a/smoothschedule/smoothschedule/platform/admin/urls.py b/smoothschedule/smoothschedule/platform/admin/urls.py
index a1aec562..55a56754 100644
--- a/smoothschedule/smoothschedule/platform/admin/urls.py
+++ b/smoothschedule/smoothschedule/platform/admin/urls.py
@@ -17,6 +17,8 @@ from .views import (
StripeWebhookRotateSecretView,
OAuthSettingsView,
PlatformEmailAddressViewSet,
+ PlatformEmailTemplateViewSet,
+ PlatformStaffInvitationViewSet,
)
app_name = 'platform'
@@ -27,6 +29,8 @@ router.register(r'users', PlatformUserViewSet, basename='user')
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
router.register(r'email-addresses', PlatformEmailAddressViewSet, basename='email-address')
+router.register(r'email-templates', PlatformEmailTemplateViewSet, basename='email-template')
+router.register(r'staff-invitations', PlatformStaffInvitationViewSet, basename='staff-invitation')
urlpatterns = [
path('', include(router.urls)),
@@ -54,4 +58,16 @@ urlpatterns = [
TenantInvitationViewSet.as_view({'post': 'accept'}),
name='tenant-invitation-accept'
),
+
+ # Public endpoints for platform staff invitations
+ path(
+ 'staff-invitations/token//',
+ PlatformStaffInvitationViewSet.as_view({'get': 'retrieve_by_token'}),
+ name='staff-invitation-retrieve-by-token'
+ ),
+ path(
+ 'staff-invitations/token//accept/',
+ PlatformStaffInvitationViewSet.as_view({'post': 'accept'}),
+ name='staff-invitation-accept'
+ ),
]
diff --git a/smoothschedule/smoothschedule/platform/admin/views.py b/smoothschedule/smoothschedule/platform/admin/views.py
index 0de87e05..772323d6 100644
--- a/smoothschedule/smoothschedule/platform/admin/views.py
+++ b/smoothschedule/smoothschedule/platform/admin/views.py
@@ -19,7 +19,10 @@ from smoothschedule.identity.core.models import Tenant, Domain
from smoothschedule.identity.users.models import User
from smoothschedule.billing.models import TenantCustomTier
from smoothschedule.billing.api.serializers import TenantCustomTierSerializer
-from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
+from .models import (
+ TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress,
+ PlatformEmailTemplate, PlatformEmailType, PlatformStaffInvitation,
+)
from .serializers import (
TenantSerializer,
TenantCreateSerializer,
@@ -40,6 +43,9 @@ from .serializers import (
PlatformEmailAddressSerializer,
PlatformEmailAddressCreateSerializer,
PlatformEmailAddressUpdateSerializer,
+ PlatformEmailTemplateListSerializer,
+ PlatformEmailTemplateDetailSerializer,
+ PlatformEmailTemplatePreviewSerializer,
)
from .permissions import IsPlatformAdmin, IsPlatformUser
@@ -1046,7 +1052,7 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('-date_joined')
serializer_class = PlatformUserSerializer
permission_classes = [IsAuthenticated, IsPlatformAdmin]
- http_method_names = ['get', 'post', 'patch', 'head', 'options'] # Allow GET, POST, and PATCH
+ http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
"""Optionally filter by business or role"""
@@ -1074,7 +1080,73 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
user.save(update_fields=['email_verified'])
return Response({'status': 'email verified'})
+ def destroy(self, request, *args, **kwargs):
+ """
+ Delete a platform user.
+ Only superusers can delete users.
+ Users cannot delete themselves.
+ """
+ instance = self.get_object()
+ # Only superusers can delete users
+ if request.user.role != User.Role.SUPERUSER:
+ return Response(
+ {"detail": "Only superusers can delete users."},
+ status=status.HTTP_403_FORBIDDEN
+ )
+
+ # Prevent self-deletion
+ if instance.id == request.user.id:
+ return Response(
+ {"detail": "You cannot delete your own account."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ user_id = instance.id
+
+ # For tenant users, we need to handle related objects in their tenant schema
+ if instance.tenant:
+ from django_tenants.utils import schema_context
+ with schema_context(instance.tenant.schema_name):
+ # Unlink resources from this user
+ from smoothschedule.scheduling.schedule.models import Resource
+ Resource.objects.filter(user_id=user_id).update(user=None)
+
+ # Delete or unlink contracts
+ try:
+ from smoothschedule.scheduling.contracts.models import Contract
+ Contract.objects.filter(customer_id=user_id).delete()
+ except Exception:
+ pass # Table might not exist
+
+ # For platform staff (no tenant) or after cleaning up tenant relations,
+ # use raw SQL to avoid Django's collector trying to query non-existent tables
+ from django.db import connection
+ with connection.cursor() as cursor:
+ # Delete all related objects in public schema that reference user_id
+ # Order matters - delete FK references before the user
+ cursor.execute("DELETE FROM authtoken_token WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM account_emailaddress WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM activepieces_tenantactivepiecesuser WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM billing_quotabannerdismissal WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM django_admin_log WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM mfa_authenticator WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM socialaccount_socialaccount WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM staff_email_emailcontactsuggestion WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM staff_email_staffemailfolder WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM staff_email_staffemaillabel WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM users_emailverificationtoken WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM users_mfaverificationcode WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM users_trusteddevice WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM users_user_groups WHERE user_id = %s", [user_id])
+ cursor.execute("DELETE FROM users_user_user_permissions WHERE user_id = %s", [user_id])
+ # Platform-specific: clear invitation references and invited_by references
+ cursor.execute("UPDATE platform_admin_platformstaffinvitation SET accepted_user_id = NULL WHERE accepted_user_id = %s", [user_id])
+ cursor.execute("UPDATE platform_admin_platformstaffinvitation SET invited_by_id = NULL WHERE invited_by_id = %s", [user_id])
+ # Finally delete the user
+ cursor.execute("DELETE FROM users_user WHERE id = %s", [user_id])
+
+ return Response(status=status.HTTP_204_NO_CONTENT)
def partial_update(self, request, *args, **kwargs):
"""
@@ -1114,23 +1186,6 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
setattr(instance, field, role_value)
- elif field == 'permissions':
- # Merge permissions - don't replace entirely
- current_permissions = instance.permissions or {}
- new_permissions = request.data[field]
-
- # Only allow granting permissions that the current user has
- for perm_key, perm_value in new_permissions.items():
- if perm_key == 'can_approve_plugins':
- # Only superusers or users with this permission can grant it
- if user.role == User.Role.SUPERUSER or user.permissions.get('can_approve_plugins', False):
- current_permissions[perm_key] = perm_value
- elif perm_key == 'can_whitelist_urls':
- # Only superusers or users with this permission can grant it
- if user.role == User.Role.SUPERUSER or user.permissions.get('can_whitelist_urls', False):
- current_permissions[perm_key] = perm_value
-
- instance.permissions = current_permissions
else:
setattr(instance, field, request.data[field])
@@ -1624,3 +1679,492 @@ class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
'skipped_count': len(skipped),
'message': f'Imported {len(imported)} email addresses, skipped {len(skipped)}',
})
+
+
+class PlatformEmailTemplateViewSet(viewsets.ViewSet):
+ """
+ ViewSet for managing platform email templates.
+ These are templates for platform-level emails (invitations, billing alerts, etc.)
+ managed via Puck visual editor.
+
+ Superusers only.
+ """
+ permission_classes = [IsAuthenticated]
+
+ def _check_superuser(self, request):
+ """Check that the request user is a superuser."""
+ if not request.user.is_superuser:
+ return Response(
+ {"detail": "Only superusers can manage platform email templates."},
+ status=status.HTTP_403_FORBIDDEN
+ )
+ return None
+
+ def list(self, request):
+ """
+ GET /api/platform/email-templates/
+ List all platform email templates.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ # Ensure all templates exist
+ PlatformEmailTemplate.ensure_all_templates_exist()
+
+ templates = PlatformEmailTemplate.objects.all().order_by('email_type')
+ serializer = PlatformEmailTemplateListSerializer(templates, many=True)
+ return Response(serializer.data)
+
+ def retrieve(self, request, pk=None):
+ """
+ GET /api/platform/email-templates/{type}/
+ Get a specific platform email template by type.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ template = PlatformEmailTemplate.get_or_create_for_type(pk)
+ except ValueError as e:
+ return Response(
+ {"detail": str(e)},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ serializer = PlatformEmailTemplateDetailSerializer(template)
+ return Response(serializer.data)
+
+ def update(self, request, pk=None):
+ """
+ PUT /api/platform/email-templates/{type}/
+ Update a platform email template.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ template = PlatformEmailTemplate.get_or_create_for_type(pk)
+ except ValueError as e:
+ return Response(
+ {"detail": str(e)},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ serializer = PlatformEmailTemplateDetailSerializer(
+ template,
+ data=request.data,
+ partial=True
+ )
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+
+ return Response(serializer.data)
+
+ @action(detail=True, methods=['post'])
+ def reset(self, request, pk=None):
+ """
+ POST /api/platform/email-templates/{type}/reset/
+ Reset a platform email template to its default.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ template = PlatformEmailTemplate.get_or_create_for_type(pk)
+ except ValueError as e:
+ return Response(
+ {"detail": str(e)},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ template.reset_to_default()
+ serializer = PlatformEmailTemplateDetailSerializer(template)
+ return Response({
+ "detail": "Template reset to default.",
+ "template": serializer.data
+ })
+
+ @action(detail=True, methods=['post'])
+ def preview(self, request, pk=None):
+ """
+ POST /api/platform/email-templates/{type}/preview/
+ Preview a rendered platform email template.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ template = PlatformEmailTemplate.get_or_create_for_type(pk)
+ except ValueError as e:
+ return Response(
+ {"detail": str(e)},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Validate incoming context
+ preview_serializer = PlatformEmailTemplatePreviewSerializer(data=request.data)
+ preview_serializer.is_valid(raise_exception=True)
+
+ # Get sample context
+ from .platform_email_templates import get_platform_tags
+ sample_context = {
+ tag: f"[{tag}]"
+ for tag in get_platform_tags().keys()
+ }
+
+ # Override with any provided context
+ sample_context.update(preview_serializer.validated_data.get('context', {}))
+
+ try:
+ rendered = template.render(sample_context)
+ return Response({
+ "subject": rendered['subject'],
+ "html": rendered['html'],
+ })
+ except Exception as e:
+ return Response(
+ {"detail": f"Failed to render template: {str(e)}"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+
+class PlatformStaffInvitationViewSet(viewsets.ViewSet):
+ """
+ ViewSet for managing platform staff invitations.
+ Superusers can create, list, and manage invitations for platform_manager and platform_support roles.
+ """
+ permission_classes = [IsAuthenticated]
+
+ def _check_superuser(self, request):
+ """Check that the request user is a superuser."""
+ if not request.user.is_superuser:
+ return Response(
+ {"detail": "Only superusers can manage platform staff invitations."},
+ status=status.HTTP_403_FORBIDDEN
+ )
+ return None
+
+ def list(self, request):
+ """
+ GET /api/platform/staff-invitations/
+ List all platform staff invitations.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ invitations = PlatformStaffInvitation.objects.all().order_by('-created_at')
+
+ # Filter by status if provided
+ status_filter = request.query_params.get('status')
+ if status_filter:
+ invitations = invitations.filter(status=status_filter.upper())
+
+ data = []
+ for inv in invitations:
+ data.append({
+ 'id': inv.id,
+ 'email': inv.email,
+ 'role': inv.role,
+ 'role_display': inv.get_role_display_name(),
+ 'status': inv.status,
+ 'status_display': inv.get_status_display(),
+ 'invited_by': inv.invited_by.get_full_name() if inv.invited_by else None,
+ 'invited_by_email': inv.invited_by.email if inv.invited_by else None,
+ 'created_at': inv.created_at.isoformat(),
+ 'expires_at': inv.expires_at.isoformat() if inv.expires_at else None,
+ 'accepted_at': inv.accepted_at.isoformat() if inv.accepted_at else None,
+ 'is_valid': inv.is_valid(),
+ })
+
+ return Response(data)
+
+ def create(self, request):
+ """
+ POST /api/platform/staff-invitations/
+ Create a new platform staff invitation and send the invitation email.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ email = request.data.get('email')
+ role = request.data.get('role', PlatformStaffInvitation.StaffRole.PLATFORM_SUPPORT)
+ permissions = request.data.get('permissions', {})
+ personal_message = request.data.get('personal_message', '')
+
+ if not email:
+ return Response(
+ {"detail": "Email is required."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Validate role
+ valid_roles = [r[0] for r in PlatformStaffInvitation.StaffRole.choices]
+ if role not in valid_roles:
+ return Response(
+ {"detail": f"Invalid role. Must be one of: {valid_roles}"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Check if user with this email already exists with a platform role
+ existing_user = User.objects.filter(email=email).first()
+ if existing_user and existing_user.role in [
+ User.Role.SUPERUSER,
+ User.Role.PLATFORM_MANAGER,
+ User.Role.PLATFORM_SUPPORT,
+ ]:
+ return Response(
+ {"detail": "A platform user with this email already exists."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Create the invitation
+ invitation = PlatformStaffInvitation.create_invitation(
+ email=email,
+ role=role,
+ invited_by=request.user,
+ permissions=permissions,
+ personal_message=personal_message,
+ )
+
+ # Send invitation email via Celery task
+ from .tasks import send_platform_staff_invitation_email
+ send_platform_staff_invitation_email.delay(invitation.id)
+
+ return Response({
+ 'id': invitation.id,
+ 'email': invitation.email,
+ 'role': invitation.role,
+ 'role_display': invitation.get_role_display_name(),
+ 'status': invitation.status,
+ 'expires_at': invitation.expires_at.isoformat(),
+ 'detail': 'Invitation created and email sent.',
+ }, status=status.HTTP_201_CREATED)
+
+ def retrieve(self, request, pk=None):
+ """
+ GET /api/platform/staff-invitations/{id}/
+ Get a specific platform staff invitation.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ invitation = PlatformStaffInvitation.objects.get(pk=pk)
+ except PlatformStaffInvitation.DoesNotExist:
+ return Response(
+ {"detail": "Invitation not found."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ return Response({
+ 'id': invitation.id,
+ 'email': invitation.email,
+ 'role': invitation.role,
+ 'role_display': invitation.get_role_display_name(),
+ 'role_description': invitation.get_role_description(),
+ 'status': invitation.status,
+ 'status_display': invitation.get_status_display(),
+ 'invited_by': invitation.invited_by.get_full_name() if invitation.invited_by else None,
+ 'invited_by_email': invitation.invited_by.email if invitation.invited_by else None,
+ 'created_at': invitation.created_at.isoformat(),
+ 'expires_at': invitation.expires_at.isoformat() if invitation.expires_at else None,
+ 'accepted_at': invitation.accepted_at.isoformat() if invitation.accepted_at else None,
+ 'personal_message': invitation.personal_message,
+ 'permissions': invitation.permissions,
+ 'is_valid': invitation.is_valid(),
+ })
+
+ @action(detail=True, methods=['post'])
+ def resend(self, request, pk=None):
+ """
+ POST /api/platform/staff-invitations/{id}/resend/
+ Resend the invitation email.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ invitation = PlatformStaffInvitation.objects.get(pk=pk)
+ except PlatformStaffInvitation.DoesNotExist:
+ return Response(
+ {"detail": "Invitation not found."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if not invitation.is_valid():
+ return Response(
+ {"detail": "Invitation is no longer valid. Please create a new invitation."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Extend expiration and regenerate token
+ invitation.expires_at = timezone.now() + timedelta(days=7)
+ invitation.token = secrets.token_urlsafe(32)
+ invitation.save()
+
+ # Resend email
+ from .tasks import send_platform_staff_invitation_email
+ send_platform_staff_invitation_email.delay(invitation.id)
+
+ return Response({
+ 'detail': 'Invitation email resent.',
+ 'expires_at': invitation.expires_at.isoformat(),
+ })
+
+ @action(detail=True, methods=['post'])
+ def cancel(self, request, pk=None):
+ """
+ POST /api/platform/staff-invitations/{id}/cancel/
+ Cancel a pending invitation.
+ """
+ error_response = self._check_superuser(request)
+ if error_response:
+ return error_response
+
+ try:
+ invitation = PlatformStaffInvitation.objects.get(pk=pk)
+ except PlatformStaffInvitation.DoesNotExist:
+ return Response(
+ {"detail": "Invitation not found."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if invitation.status != PlatformStaffInvitation.Status.PENDING:
+ return Response(
+ {"detail": "Only pending invitations can be cancelled."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ invitation.cancel()
+
+ return Response({
+ 'detail': 'Invitation cancelled.',
+ 'status': invitation.status,
+ })
+
+ @action(detail=False, methods=['get'], url_path='token/(?P[^/.]+)', permission_classes=[])
+ def retrieve_by_token(self, request, token=None):
+ """
+ GET /api/platform/staff-invitations/token/{token}/
+ Public endpoint to retrieve invitation details by token.
+ Used by the accept invitation page.
+ """
+ try:
+ invitation = PlatformStaffInvitation.objects.get(token=token)
+ except PlatformStaffInvitation.DoesNotExist:
+ return Response(
+ {"detail": "Invitation not found or invalid token."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if not invitation.is_valid():
+ return Response(
+ {"detail": "This invitation has expired or is no longer valid."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ return Response({
+ 'email': invitation.email,
+ 'role': invitation.role,
+ 'role_display': invitation.get_role_display_name(),
+ 'role_description': invitation.get_role_description(),
+ 'invited_by': invitation.invited_by.get_full_name() if invitation.invited_by else None,
+ 'personal_message': invitation.personal_message,
+ 'expires_at': invitation.expires_at.isoformat() if invitation.expires_at else None,
+ })
+
+ @action(detail=False, methods=['post'], url_path='token/(?P[^/.]+)/accept', permission_classes=[])
+ def accept(self, request, token=None):
+ """
+ POST /api/platform/staff-invitations/token/{token}/accept/
+ Public endpoint to accept an invitation and set up the account.
+ """
+ try:
+ invitation = PlatformStaffInvitation.objects.get(token=token)
+ except PlatformStaffInvitation.DoesNotExist:
+ return Response(
+ {"detail": "Invitation not found or invalid token."},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ if not invitation.is_valid():
+ return Response(
+ {"detail": "This invitation has expired or is no longer valid."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Validate required fields
+ password = request.data.get('password')
+ first_name = request.data.get('first_name')
+ last_name = request.data.get('last_name')
+
+ if not password:
+ return Response(
+ {"detail": "Password is required."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ if len(password) < 8:
+ return Response(
+ {"detail": "Password must be at least 8 characters."},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ with transaction.atomic():
+ # Check if user already exists (they may have a tenant account)
+ user = User.objects.filter(email=invitation.email).first()
+
+ if user:
+ # Update existing user to platform role
+ user.role = invitation.role
+ user.is_staff = True
+ user.set_password(password)
+ if first_name:
+ user.first_name = first_name
+ if last_name:
+ user.last_name = last_name
+ # Merge permissions
+ user.permissions = {**user.permissions, **invitation.permissions}
+ user.email_verified = True
+ user.save()
+ else:
+ # Create new user
+ user = User.objects.create_user(
+ username=invitation.email,
+ email=invitation.email,
+ password=password,
+ first_name=first_name or '',
+ last_name=last_name or '',
+ role=invitation.role,
+ is_staff=True,
+ permissions=invitation.permissions,
+ email_verified=True,
+ )
+
+ # Mark invitation as accepted
+ invitation.accept(user)
+
+ # Send welcome email
+ from .tasks import send_platform_staff_welcome_email
+ send_platform_staff_welcome_email.delay(user.id, invitation.role)
+
+ # Generate auth token for automatic login
+ from rest_framework.authtoken.models import Token
+ token, _ = Token.objects.get_or_create(user=user)
+
+ return Response({
+ 'detail': 'Account created successfully.',
+ 'email': user.email,
+ 'role': user.role,
+ 'access': token.key,
+ 'refresh': token.key, # Using same token since we use Token auth, not JWT
+ }, status=status.HTTP_201_CREATED)
diff --git a/smoothschedule/smoothschedule/platform/api/throttling.py b/smoothschedule/smoothschedule/platform/api/throttling.py
index 2066c05d..211c08b0 100644
--- a/smoothschedule/smoothschedule/platform/api/throttling.py
+++ b/smoothschedule/smoothschedule/platform/api/throttling.py
@@ -8,18 +8,27 @@ Rate Limits:
- Global: 1000 requests per hour per token
- Burst: 100 requests per minute (allows short bursts of traffic)
+Quota Tracking:
+- Daily API requests are tracked against the tenant's max_api_requests_per_day quota
+- Requests are blocked when the daily quota is exceeded (if not unlimited)
+
Response Headers:
- X-RateLimit-Limit: Total requests allowed per hour
- X-RateLimit-Remaining: Requests remaining in current hour
- X-RateLimit-Reset: Unix timestamp when the limit resets
- X-RateLimit-Burst-Limit: Requests allowed per minute
- X-RateLimit-Burst-Remaining: Requests remaining in current minute
+- X-Quota-Limit: Daily quota limit
+- X-Quota-Remaining: Requests remaining in daily quota
"""
+import logging
import time
from django.core.cache import cache
from rest_framework.throttling import BaseThrottle
+logger = logging.getLogger(__name__)
+
class GlobalBurstRateThrottle(BaseThrottle):
"""
@@ -52,7 +61,8 @@ class GlobalBurstRateThrottle(BaseThrottle):
"""
Check if the request should be allowed.
- Returns True if both hourly and minute limits allow the request.
+ Returns True if both hourly and minute limits allow the request
+ AND the tenant's daily API quota is not exceeded.
Stores rate limit info on the request for header generation.
"""
self.now = time.time()
@@ -90,7 +100,7 @@ class GlobalBurstRateThrottle(BaseThrottle):
'burst_remaining': minute_remaining,
}
- # Must pass both checks
+ # Must pass both rate limit checks
if not hourly_allowed or not minute_allowed:
# Determine which limit was exceeded for wait time
if not hourly_allowed:
@@ -99,8 +109,73 @@ class GlobalBurstRateThrottle(BaseThrottle):
self.wait_time = minute_reset - self.now
return False
+ # Check and track daily API quota
+ tenant = getattr(request, 'tenant', None) or getattr(self.token, 'tenant', None)
+ if tenant:
+ quota_allowed, quota_remaining = self._check_and_track_quota(tenant, request)
+ if not quota_allowed:
+ # Return 429 with a wait until midnight
+ self.wait_time = self._seconds_until_midnight()
+ self.quota_exceeded = True
+ return False
+
return True
+ def _check_and_track_quota(self, tenant, request):
+ """
+ Check daily API quota and increment the counter.
+
+ Args:
+ tenant: The tenant making the request
+ request: The HTTP request object
+
+ Returns:
+ tuple: (allowed, remaining_requests)
+ """
+ try:
+ from smoothschedule.billing.services.quota import QuotaService
+
+ # First check if allowed
+ is_allowed, remaining = QuotaService.check_api_quota(tenant)
+
+ if is_allowed:
+ # Increment the counter
+ usage, _ = QuotaService.increment_api_request_count(tenant)
+
+ # Store quota info for headers
+ request.quota_info = {
+ 'limit': usage.quota_limit,
+ 'remaining': max(0, usage.remaining_requests - 1), # -1 for this request
+ 'is_unlimited': usage.is_unlimited,
+ }
+
+ logger.debug(
+ f"API request tracked for {tenant.name}: "
+ f"{usage.request_count}/{usage.quota_limit or 'unlimited'}"
+ )
+ else:
+ # Store quota info even when blocked
+ request.quota_info = {
+ 'limit': QuotaService.get_api_request_quota_limit(tenant),
+ 'remaining': 0,
+ 'is_unlimited': False,
+ }
+
+ return is_allowed, remaining
+
+ except Exception as e:
+ # Don't block requests if quota tracking fails
+ logger.error(f"Failed to check/track API quota for {tenant.name}: {e}")
+ return True, None
+
+ def _seconds_until_midnight(self):
+ """Calculate seconds until midnight UTC (when daily quota resets)."""
+ from django.utils import timezone
+ now = timezone.now()
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ midnight += timezone.timedelta(days=1)
+ return (midnight - now).total_seconds()
+
def _check_rate(self, scope, limit, duration):
"""
Check if request is within rate limit for the given scope/duration.
@@ -149,10 +224,10 @@ class GlobalBurstRateThrottle(BaseThrottle):
class RateLimitHeadersMixin:
"""
- Mixin for views to add rate limit headers to responses.
+ Mixin for views to add rate limit and quota headers to responses.
Add this mixin to views that use GlobalBurstRateThrottle to
- automatically include rate limit headers in all responses.
+ automatically include rate limit and quota headers in all responses.
Usage:
class MyView(RateLimitHeadersMixin, APIView):
@@ -160,9 +235,10 @@ class RateLimitHeadersMixin:
"""
def finalize_response(self, request, response, *args, **kwargs):
- """Add rate limit headers to the response."""
+ """Add rate limit and quota headers to the response."""
response = super().finalize_response(request, response, *args, **kwargs)
+ # Rate limit headers
rate_limit_info = getattr(request, 'rate_limit_info', None)
if rate_limit_info:
response['X-RateLimit-Limit'] = rate_limit_info['limit']
@@ -171,19 +247,47 @@ class RateLimitHeadersMixin:
response['X-RateLimit-Burst-Limit'] = rate_limit_info['burst_limit']
response['X-RateLimit-Burst-Remaining'] = rate_limit_info['burst_remaining']
+ # Daily quota headers
+ quota_info = getattr(request, 'quota_info', None)
+ if quota_info:
+ if quota_info.get('is_unlimited'):
+ response['X-Quota-Limit'] = 'unlimited'
+ response['X-Quota-Remaining'] = 'unlimited'
+ else:
+ response['X-Quota-Limit'] = quota_info['limit']
+ response['X-Quota-Remaining'] = quota_info['remaining']
+
return response
-def get_throttle_response_data(request):
+def get_throttle_response_data(request, quota_exceeded=False):
"""
Get data for a 429 Too Many Requests response.
Args:
request: The HTTP request object
+ quota_exceeded: If True, the daily quota was exceeded (not rate limit)
Returns:
dict: Response data with error details and retry info
"""
+ if quota_exceeded:
+ # Daily quota exceeded
+ quota_info = getattr(request, 'quota_info', {})
+ from django.utils import timezone
+ now = timezone.now()
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ midnight += timezone.timedelta(days=1)
+ retry_after = int((midnight - now).total_seconds())
+
+ return {
+ 'error': 'quota_exceeded',
+ 'message': 'Daily API quota exceeded. Your quota resets at midnight UTC.',
+ 'retry_after': retry_after,
+ 'quota_limit': quota_info.get('limit', 0),
+ }
+
+ # Standard rate limit exceeded
rate_limit_info = getattr(request, 'rate_limit_info', {})
reset_time = rate_limit_info.get('reset', int(time.time()) + 60)
retry_after = max(1, reset_time - int(time.time()))
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/api_views.py b/smoothschedule/smoothschedule/scheduling/schedule/api_views.py
index fddeca9a..313e9b30 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/api_views.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/api_views.py
@@ -169,14 +169,12 @@ def current_business_view(request):
'webhooks': tenant.has_feature('integrations_enabled'),
'api_access': tenant.has_feature('api_access'),
'custom_domain': tenant.has_feature('custom_domain'),
- 'custom_branding': tenant.has_feature('custom_branding'),
- 'remove_branding': tenant.has_feature('remove_branding'),
+ 'white_label': tenant.has_feature('can_white_label'),
'custom_oauth': tenant.has_feature('can_manage_oauth'),
'automations': tenant.has_feature('can_use_automations'),
'can_create_automations': tenant.has_feature('can_create_automations'),
'tasks': tenant.has_feature('can_use_tasks'),
'export_data': tenant.has_feature('can_export_data'),
- 'video_conferencing': tenant.has_feature('can_add_video_conferencing'),
'two_factor_auth': tenant.has_feature('team_permissions'),
'masked_calling': tenant.has_feature('masked_calling_enabled'),
'pos_system': tenant.has_feature('can_use_pos'),
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/views.py b/smoothschedule/smoothschedule/scheduling/schedule/views.py
index ae5da907..985ef5bf 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/views.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/views.py
@@ -24,6 +24,7 @@ from .serializers import (
)
from .services import LocationService
from .models import Service
+from smoothschedule.billing.services.quota import QuotaService
from smoothschedule.identity.core.permissions import HasQuota
from smoothschedule.identity.core.mixins import (
TenantFilteredQuerySetMixin,
@@ -497,13 +498,20 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
def perform_create(self, serializer):
"""
- Create event with automatic availability validation.
+ Create event with automatic availability validation and quota tracking.
The EventSerializer.validate() method calls AvailabilityService
to check if resources have capacity. If not, DRF automatically
returns 400 Bad Request with error details.
+
+ After successful creation, increments the tenant's appointment quota.
"""
- serializer.save(created_by=self.request.user)
+ event = serializer.save(created_by=self.request.user)
+
+ # Track quota usage (if tenant context exists)
+ tenant = getattr(self.request, 'tenant', None)
+ if tenant:
+ QuotaService.increment_appointment_count(tenant, count=1)
def perform_update(self, serializer):
"""
@@ -514,6 +522,21 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
"""
serializer.save()
+ def perform_destroy(self, instance):
+ """
+ Delete event and decrement quota.
+
+ Note: Overage counts are NOT decremented (once billed, stays billed).
+ """
+ tenant = getattr(self.request, 'tenant', None)
+
+ # Delete the event
+ instance.delete()
+
+ # Decrement quota usage
+ if tenant:
+ QuotaService.decrement_appointment_count(tenant, count=1)
+
@action(detail=True, methods=['post'])
def set_status(self, request, pk=None):
"""
@@ -559,6 +582,8 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
status_machine = StatusMachine(tenant, request.user)
+ old_status = event.status
+
try:
event = status_machine.transition(
event=event,
@@ -570,6 +595,10 @@ class EventViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
skip_notifications=skip_notifications,
)
+ # Decrement quota when event is canceled (unless it was already canceled)
+ if new_status == Event.Status.CANCELED and old_status != Event.Status.CANCELED:
+ QuotaService.decrement_appointment_count(tenant, count=1)
+
serializer = self.get_serializer(event)
return Response({
'success': True,