Add platform email templates, staff invitations, and quota tracking

- Add PlatformEmailTemplate model and API for superuser-managed email templates
- Add PlatformStaffInvitation model with email sending via Celery tasks
- Add platform staff invite page and acceptance flow with auto-login
- Add quota tracking models (DailyAppointmentUsage, DailyAPIUsage, StorageUsage)
- Add quota status API endpoints and frontend banners
- Add storage usage service for tenant media tracking
- Fix platform user deletion with raw SQL to handle multi-tenant FK constraints
- Update EditPlatformUserModal with archive/delete buttons
- Update PlatformSidebar with email templates link for superusers
- Configure console email backend and Celery eager mode for local development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2026-01-01 10:35:35 -05:00
parent f13a40e4bc
commit fc63cf4fce
66 changed files with 8145 additions and 666 deletions

View File

@@ -66,10 +66,12 @@ const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'))
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
const PlatformEmailTemplates = React.lazy(() => import('./pages/platform/PlatformEmailTemplates'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const PlatformStaffInvitePage = React.lazy(() => import('./pages/platform/PlatformStaffInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
@@ -375,6 +377,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -411,6 +414,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -419,10 +423,10 @@ const AppContent: React.FC = () => {
);
}
// For platform subdomain, only /platform/login exists - everything else renders nothing
// For platform subdomain, only specific paths exist - everything else renders nothing
if (isPlatformSubdomain) {
const path = window.location.pathname;
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email', '/platform-staff-invite'];
// If not an allowed path, render nothing
if (!allowedPaths.includes(path)) {
@@ -435,6 +439,7 @@ const AppContent: React.FC = () => {
<Route path="/platform/login" element={<PlatformLoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
</Routes>
</Suspense>
);
@@ -460,6 +465,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -599,6 +605,7 @@ const AppContent: React.FC = () => {
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
<Route path="/platform/email-templates" element={<PlatformEmailTemplates />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />

View File

@@ -9,6 +9,7 @@
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X } from 'lucide-react';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
import { isWipFeature } from '../featureCatalog';
export interface FeaturePickerProps {
/** Available features from the API */
@@ -168,8 +169,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
{feature.code}
@@ -219,8 +225,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white block">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{feature.code}

View File

@@ -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
*/

View File

@@ -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<AppointmentQuotaBannerProps> = ({ 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 (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-red-500 to-red-600 text-white'
: 'bg-gradient-to-r from-amber-400 to-amber-500 text-amber-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-amber-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<TrendingUp className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.appointmentBanner.overTitle', 'Appointment Quota Exceeded')
: t('quota.appointmentBanner.warningTitle', 'Approaching Appointment Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<Calendar className="h-4 w-4 hidden sm:inline" />
{t('quota.appointmentBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: appointments.count,
limit: appointments.limit,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && appointments.overage_count > 0 && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.appointmentBanner.overage', '+{{count}} @ $0.10 each', {
count: appointments.overage_count,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-red-600 hover:bg-red-50'
: 'bg-amber-700 text-white hover:bg-amber-800'
}`}
>
{t('quota.appointmentBanner.upgrade', 'Upgrade Plan')}
</Link>
<button
onClick={handleDismiss}
disabled={dismissMutation.isPending}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-amber-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.appointmentBanner.overageInfo',
'Appointments over your limit will be billed at $0.10 each at the end of your billing cycle.'
)}
</div>
)}
</div>
</div>
);
};
export default AppointmentQuotaBanner;

View File

@@ -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<PlatformSidebarProps> = ({ user, isCollapsed, to
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/email-templates" className={getNavClass('/platform/email-templates')} title={t('nav.emailTemplates', 'Email Templates')}>
<FileText size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.emailTemplates', 'Email Templates')}</span>}
</Link>
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
<CreditCard size={18} className="shrink-0" />
{!isCollapsed && <span>Billing</span>}

View File

@@ -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<StorageQuotaBannerProps> = ({ 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 (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-purple-600 to-purple-700 text-white'
: 'bg-gradient-to-r from-purple-400 to-purple-500 text-purple-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-purple-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<Database className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.storageBanner.overTitle', 'Storage Quota Exceeded')
: t('quota.storageBanner.warningTitle', 'Approaching Storage Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<HardDrive className="h-4 w-4 hidden sm:inline" />
{t('quota.storageBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: currentDisplay,
limit: limitDisplay,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && overageDisplay && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.storageBanner.overage', '+{{size}} @ $0.50/GB', {
size: overageDisplay,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-purple-600 hover:bg-purple-50'
: 'bg-purple-700 text-white hover:bg-purple-800'
}`}
>
{t('quota.storageBanner.upgrade', 'Upgrade Plan')}
</Link>
{onDismiss && (
<button
onClick={onDismiss}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-purple-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.storageBanner.overageInfo',
'Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.'
)}
</div>
)}
{/* Last measured timestamp */}
{lastMeasuredDisplay && (
<div className="mt-1 text-xs opacity-70">
{t('quota.storageBanner.lastMeasured', 'Last measured: {{time}}', {
time: lastMeasuredDisplay,
})}
</div>
)}
</div>
</div>
);
};
export default StorageQuotaBanner;

View File

@@ -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' },
],
},
{

View File

@@ -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<DynamicFeaturesEditorProps> = ({
@@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
headerTitle = 'Features & Permissions',
showDescriptions = false,
columns = 3,
disabled = false,
}) => {
const { data: features, isLoading, error } = useBillingFeatures();
@@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
if (feature.feature_type === 'boolean') {
const isChecked = currentValue === true;
const isInputDisabled = isDisabled || disabled;
return (
<label
key={feature.code}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
isInputDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
@@ -237,12 +245,17 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(feature, e.target.checked)}
disabled={isDisabled}
disabled={isInputDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
@@ -261,10 +274,15 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
return (
<div
key={feature.code}
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
className={`p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${disabled ? 'opacity-50' : ''}`}
>
<label className="text-sm text-gray-700 dark:text-gray-300 block mb-1">
<label className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5 mb-1">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</label>
<div className="flex items-center gap-2">
<input
@@ -275,7 +293,8 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
const val = parseInt(e.target.value);
handleChange(feature, val === -1 ? null : val);
}}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500"
disabled={disabled}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{showDescriptions && (

View File

@@ -12,6 +12,7 @@
import React from 'react';
import { Key } from 'lucide-react';
import { isWipFeature } from '../../billing/featureCatalog';
/**
* Permission definition with metadata
@@ -117,21 +118,21 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
category: 'customization',
},
{
key: 'remove_branding',
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'Remove Branding',
description: 'Remove SmoothSchedule branding from customer-facing pages',
label: 'White Label',
description: 'Customize branding and remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
// Automations
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
key: 'automations',
planKey: 'can_use_automations',
businessKey: 'can_use_automations',
label: 'Automations',
description: 'Install and use marketplace automations',
category: 'plugins',
},
{
@@ -141,16 +142,16 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
key: 'create_automations',
planKey: 'can_create_automations',
businessKey: 'can_create_automations',
label: 'Create Automations',
description: 'Build custom automations',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
// Advanced Features
@@ -172,7 +173,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
planKey: 'can_use_calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
@@ -186,14 +187,6 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
@@ -240,8 +233,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
label: 'Single Sign-On (SSO)',
description: 'Enable SSO/SAML authentication for team members',
category: 'enterprise',
},
{
@@ -499,8 +492,13 @@ const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{def.label}
{isWipFeature(def.planKey || def.key) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">

View File

@@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
// Platform
can_api_access: 'api_access',
can_use_custom_domain: 'custom_domain',
can_white_label: 'remove_branding',
can_white_label: 'can_white_label',
// Features
can_accept_payments: 'payment_processing',
@@ -328,8 +328,9 @@ export function planFeaturesToLegacyPermissions(
case 'custom_domain':
permissions.can_use_custom_domain = value as boolean;
break;
case 'remove_branding':
case 'can_white_label':
permissions.can_white_label = value as boolean;
permissions.can_customize_booking_page = value as boolean;
break;
case 'payment_processing':
permissions.can_accept_payments = value as boolean;
@@ -356,9 +357,6 @@ export function planFeaturesToLegacyPermissions(
case 'audit_logs':
permissions.can_download_logs = value as boolean;
break;
case 'custom_branding':
permissions.can_customize_booking_page = value as boolean;
break;
case 'recurring_appointments':
permissions.can_book_repeated_events = value as boolean;
break;

View File

@@ -60,13 +60,12 @@ export const useCurrentBusiness = () => {
webhooks: false,
api_access: false,
custom_domain: false,
remove_branding: false,
white_label: false,
custom_oauth: false,
automations: false,
can_create_automations: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,

View File

@@ -81,14 +81,12 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
webhooks: 'Webhooks',
api_access: 'API Access',
custom_domain: 'Custom Domain',
custom_branding: 'Custom Branding',
remove_branding: 'Remove Branding',
white_label: 'White Label',
custom_oauth: 'Custom OAuth',
automations: 'Automations',
can_create_automations: 'Custom Automation Creation',
tasks: 'Scheduled Tasks',
export_data: 'Data Export',
video_conferencing: 'Video Conferencing',
two_factor_auth: 'Two-Factor Authentication',
masked_calling: 'Masked Calling',
pos_system: 'POS System',
@@ -105,14 +103,12 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
webhooks: 'Integrate with external services using webhooks',
api_access: 'Access the SmoothSchedule API for custom integrations',
custom_domain: 'Use your own custom domain for your booking site',
custom_branding: 'Customize branding colors, logo, and styling',
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
white_label: 'Customize branding and remove SmoothSchedule branding',
custom_oauth: 'Configure your own OAuth credentials for social login',
automations: 'Automate repetitive tasks with custom workflows',
can_create_automations: 'Create custom automations tailored to your business needs',
tasks: 'Create scheduled tasks to automate execution',
export_data: 'Export your data to CSV or other formats',
video_conferencing: 'Add video conferencing links to appointments',
two_factor_auth: 'Require two-factor authentication for enhanced security',
masked_calling: 'Use masked phone numbers to protect privacy',
pos_system: 'Process in-person payments with Point of Sale',

View File

@@ -0,0 +1,116 @@
/**
* Platform Email Templates Hooks
*
* React Query hooks for managing platform-level email templates.
* These templates are used for platform communications like tenant invitations,
* trial notifications, billing alerts, etc.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTemplatePreview,
PlatformEmailTemplateUpdate,
PlatformEmailType,
} from '../types';
// Query keys
const QUERY_KEYS = {
all: ['platform-email-templates'] as const,
detail: (type: PlatformEmailType) => ['platform-email-templates', type] as const,
};
/**
* Fetch all platform email templates
*/
export function usePlatformEmailTemplates() {
return useQuery<PlatformEmailTemplate[]>({
queryKey: QUERY_KEYS.all,
queryFn: async () => {
const { data } = await api.get('/platform/email-templates/');
return data;
},
});
}
/**
* Fetch a single platform email template by type
*/
export function usePlatformEmailTemplate(emailType: PlatformEmailType | null) {
return useQuery<PlatformEmailTemplateDetail>({
queryKey: emailType ? QUERY_KEYS.detail(emailType) : ['platform-email-templates', 'none'],
queryFn: async () => {
if (!emailType) throw new Error('No email type specified');
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
},
enabled: !!emailType,
});
}
/**
* Update a platform email template
*/
export function useUpdatePlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
emailType,
data,
}: {
emailType: PlatformEmailType;
data: PlatformEmailTemplateUpdate;
}) => {
const response = await api.put(`/platform/email-templates/${emailType}/`, data);
return response.data as PlatformEmailTemplateDetail;
},
onSuccess: (_, variables) => {
// Invalidate both the list and the specific template
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(variables.emailType) });
},
});
}
/**
* Reset a platform email template to its default
*/
export function useResetPlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (emailType: PlatformEmailType) => {
const response = await api.post(`/platform/email-templates/${emailType}/reset/`);
return response.data;
},
onSuccess: (_, emailType) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(emailType) });
},
});
}
/**
* Preview a rendered platform email template
*/
export function usePreviewPlatformEmailTemplate() {
return useMutation({
mutationFn: async ({
emailType,
context = {},
}: {
emailType: PlatformEmailType;
context?: Record<string, string>;
}) => {
const response = await api.post(`/platform/email-templates/${emailType}/preview/`, {
context,
});
return response.data as PlatformEmailTemplatePreview;
},
});
}

View File

@@ -0,0 +1,178 @@
/**
* Platform Staff Invitations Hooks
*
* React Query hooks for managing platform staff invitations.
* These are used to invite new platform_manager and platform_support users.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
// Types
export type PlatformStaffRole = 'platform_manager' | 'platform_support';
export interface PlatformStaffInvitation {
id: number;
email: string;
role: PlatformStaffRole;
role_display: string;
status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
status_display: string;
invited_by: string | null;
invited_by_email: string | null;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
is_valid: boolean;
}
export interface PlatformStaffInvitationDetail extends PlatformStaffInvitation {
role_description: string;
personal_message: string;
permissions: Record<string, boolean>;
}
export interface CreatePlatformStaffInvitationData {
email: string;
role: PlatformStaffRole;
personal_message?: string;
}
export interface AcceptPlatformStaffInvitationData {
password: string;
first_name?: string;
last_name?: string;
}
export interface PlatformStaffInvitationTokenData {
email: string;
role: PlatformStaffRole;
role_display: string;
role_description: string;
invited_by: string | null;
personal_message: string;
expires_at: string | null;
}
// Query keys
const QUERY_KEYS = {
all: ['platform-staff-invitations'] as const,
detail: (id: number) => ['platform-staff-invitations', id] as const,
token: (token: string) => ['platform-staff-invitations', 'token', token] as const,
};
/**
* Fetch all platform staff invitations
*/
export function usePlatformStaffInvitations(statusFilter?: string) {
return useQuery<PlatformStaffInvitation[]>({
queryKey: [...QUERY_KEYS.all, { status: statusFilter }],
queryFn: async () => {
const params = statusFilter ? { status: statusFilter } : {};
const { data } = await api.get('/platform/staff-invitations/', { params });
return data;
},
});
}
/**
* Fetch a single platform staff invitation by ID
*/
export function usePlatformStaffInvitation(id: number | null) {
return useQuery<PlatformStaffInvitationDetail>({
queryKey: id ? QUERY_KEYS.detail(id) : ['platform-staff-invitations', 'none'],
queryFn: async () => {
if (!id) throw new Error('No invitation ID specified');
const { data } = await api.get(`/platform/staff-invitations/${id}/`);
return data;
},
enabled: !!id,
});
}
/**
* Create a new platform staff invitation
*/
export function useCreatePlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationData: CreatePlatformStaffInvitationData) => {
const response = await api.post('/platform/staff-invitations/', invitationData);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Resend a platform staff invitation email
*/
export function useResendPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/resend/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Cancel a platform staff invitation
*/
export function useCancelPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/cancel/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Fetch invitation details by token (public endpoint)
*/
export function usePlatformStaffInvitationByToken(token: string | null) {
return useQuery<PlatformStaffInvitationTokenData>({
queryKey: token ? QUERY_KEYS.token(token) : ['platform-staff-invitations', 'token', 'none'],
queryFn: async () => {
if (!token) throw new Error('No token specified');
const { data } = await api.get(`/platform/staff-invitations/token/${token}/`);
return data;
},
enabled: !!token,
retry: false, // Don't retry on failure (invalid token)
});
}
/**
* Accept a platform staff invitation (public endpoint)
*/
export function useAcceptPlatformStaffInvitation() {
return useMutation({
mutationFn: async ({
token,
data,
}: {
token: string;
data: AcceptPlatformStaffInvitationData;
}) => {
const response = await api.post(`/platform/staff-invitations/token/${token}/accept/`, data);
return response.data;
},
});
}

View File

@@ -0,0 +1,111 @@
/**
* useQuotaStatus Hook
*
* Fetches monthly quota status from the billing API.
* Used to show warning banners when approaching quota limits.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface QuotaStatus {
billing_period: {
year: number;
month: number;
};
appointments: {
count: number;
limit: number;
is_unlimited: boolean;
usage_percentage: number;
remaining: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_count: number;
overage_amount_cents: number;
};
flow_executions: {
count: number;
amount_cents: number;
};
api_requests: {
date: string;
request_count: number;
quota_limit: number;
is_unlimited: boolean;
is_over_quota: boolean;
remaining_requests: number | null;
usage_percentage: number;
};
storage: {
current_size_mb: number;
current_size_gb: number;
peak_size_mb: number;
quota_limit_mb: number;
quota_limit_gb: number;
is_unlimited: boolean;
usage_percentage: number;
remaining_mb: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_mb: number;
overage_amount_cents: number;
warning_email_sent: boolean;
last_measured_at: string | null;
};
warning: {
show_banner: boolean;
email_sent: boolean;
};
}
/**
* Fetch current quota status
*/
export const fetchQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get('/me/quota/');
return response.data;
};
/**
* Dismiss the quota warning banner for the current billing period
*/
export const dismissQuotaBanner = async (): Promise<void> => {
await apiClient.post('/me/quota/dismiss-banner/');
};
/**
* Hook to fetch quota status
*/
export const useQuotaStatus = () => {
return useQuery({
queryKey: ['quotaStatus'],
queryFn: fetchQuotaStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to dismiss the quota warning banner
*/
export const useDismissQuotaBanner = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dismissQuotaBanner,
onSuccess: () => {
// Update the cached quota status to hide the banner
queryClient.setQueryData<QuotaStatus>(['quotaStatus'], (old) => {
if (!old) return old;
return {
...old,
warning: {
...old.warning,
show_banner: false,
},
};
});
},
});
};

View File

@@ -2368,6 +2368,74 @@
"staff": "Staff",
"customer": "Customer"
},
"emailTemplates": {
"title": "Platform Email Templates",
"description": "Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.",
"aboutTags": "About Template Tags",
"tagsDescription": "Use template tags like {{ tenant_name }} to insert dynamic content. Available tags vary by email type and are shown when editing each template.",
"categories": {
"invitation": "Invitations",
"trial": "Trial & Onboarding",
"subscription": "Subscription Changes",
"billing": "Billing & Payments"
},
"types": {
"tenant_invitation": {
"name": "Tenant Invitation",
"description": "Sent when inviting a new business to join the platform"
},
"trial_expiration_warning": {
"name": "Trial Expiration Warning",
"description": "Sent a few days before a business trial expires"
},
"trial_expired": {
"name": "Trial Expired",
"description": "Sent when a business trial has expired"
},
"plan_upgrade": {
"name": "Plan Upgrade Confirmation",
"description": "Sent when a business upgrades their subscription plan"
},
"plan_downgrade": {
"name": "Plan Downgrade Confirmation",
"description": "Sent when a business downgrades their subscription plan"
},
"subscription_cancelled": {
"name": "Subscription Cancelled",
"description": "Sent when a business cancels their subscription"
},
"payment_failed": {
"name": "Payment Failed",
"description": "Sent when a recurring payment fails"
},
"payment_succeeded": {
"name": "Payment Succeeded",
"description": "Sent after a successful subscription payment"
}
},
"customized": "Customized",
"disabled": "Disabled",
"subject": "Subject",
"edit": "Edit",
"resetToDefault": "Reset to Default",
"resetConfirmTitle": "Reset to Default?",
"resetConfirmMessage": "This will reset the email template to its default content. Any customizations you've made will be lost.",
"cancel": "Cancel",
"save": "Save",
"preview": "Preview",
"unsavedChanges": "Unsaved changes",
"emailSubject": "Email Subject",
"subjectPlaceholder": "Enter email subject...",
"subjectHint": "Use tags like {{ tenant_name }} for dynamic content",
"availableTags": "Available Template Tags",
"clickToCopy": "Click a tag to copy it. Hover for description.",
"emailPreview": "Email Preview",
"close": "Close",
"failedToLoad": "Failed to load template",
"failedToSave": "Failed to save template",
"failedToReset": "Failed to reset template",
"failedToPreview": "Failed to generate preview"
},
"settings": {
"title": "Platform Settings",
"description": "Configure platform-wide settings and integrations",
@@ -2777,8 +2845,7 @@
"api_access": "API access",
"max_api_calls_per_day": "API calls/day",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"remove_branding": "Remove branding",
"can_white_label": "White label branding",
"multi_location": "Multi-location management",
"team_permissions": "Team permissions",
"audit_logs": "Audit logs",
@@ -3423,6 +3490,23 @@
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
"noOverages": "You are within your plan limits.",
"resolved": "Resolved! Your usage is now within limits."
},
"appointmentBanner": {
"warningTitle": "Approaching Appointment Limit",
"overTitle": "Appointment Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{count}} @ $0.10 each",
"upgrade": "Upgrade Plan",
"overageInfo": "Appointments over your limit will be billed at $0.10 each at the end of your billing cycle."
},
"storageBanner": {
"warningTitle": "Approaching Storage Limit",
"overTitle": "Storage Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{size}} @ $0.50/GB",
"upgrade": "Upgrade Plan",
"overageInfo": "Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.",
"lastMeasured": "Last measured: {{time}}"
}
},
"upgrade": {

View File

@@ -6,6 +6,8 @@ import TrialBanner from '../components/TrialBanner';
import SandboxBanner from '../components/SandboxBanner';
import QuotaWarningBanner from '../components/QuotaWarningBanner';
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal';
import AppointmentQuotaBanner from '../components/AppointmentQuotaBanner';
import StorageQuotaBanner from '../components/StorageQuotaBanner';
import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard';
@@ -200,7 +202,7 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
onStop={handleStopMasquerade}
/>
)}
{/* Quota overage warning banner - show for owners and managers */}
{/* Quota overage warning banner - show for owners and managers (grace period system) */}
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaWarningBanner overages={user.quota_overages} />
)}
@@ -208,6 +210,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaOverageModal overages={user.quota_overages} onDismiss={() => {}} />
)}
{/* Appointment quota warning banner - 90% threshold warning (billing cycle system) */}
<AppointmentQuotaBanner userRole={user.role} />
{/* Storage quota warning banner - 90% threshold warning */}
<StorageQuotaBanner userRole={user.role} />
{/* Sandbox mode banner */}
<SandboxBannerWrapper />
{/* Show trial banner if trial is active and payments not yet enabled */}

View File

@@ -44,7 +44,7 @@ interface ParentContext {
// Map settings pages to their required plan features
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
'/dashboard/settings/branding': 'custom_branding',
'/dashboard/settings/branding': 'white_label',
'/dashboard/settings/custom-domains': 'custom_domain',
'/dashboard/settings/api': 'api_access',
'/dashboard/settings/authentication': 'custom_oauth',

View File

@@ -61,7 +61,7 @@ const transformPuckDataForEditor = (puckData: any): any => {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}

View File

@@ -0,0 +1,635 @@
/**
* Platform Email Templates Page
*
* Allows superusers to customize platform-level automated emails
* (tenant invitations, trial notifications, billing alerts, etc.) using a Puck editor.
*
* Access: Superusers only
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import {
Mail,
Edit2,
RotateCcw,
Eye,
X,
AlertTriangle,
UserPlus,
Clock,
CreditCard,
Package,
ChevronDown,
ChevronRight,
Info,
Save,
Loader2,
Code,
MonitorPlay,
} from 'lucide-react';
import { getEmailEditorConfig } from '../../puck/emailConfig';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTag,
PlatformEmailCategory,
PlatformEmailType,
} from '../../types';
import {
usePlatformEmailTemplates,
useUpdatePlatformEmailTemplate,
useResetPlatformEmailTemplate,
usePreviewPlatformEmailTemplate,
} from '../../hooks/usePlatformEmailTemplates';
import api from '../../api/client';
// Category metadata
const CATEGORY_CONFIG: Record<PlatformEmailCategory, {
label: string;
icon: React.ReactNode;
color: string;
}> = {
invitation: {
label: 'Invitations',
icon: <UserPlus className="h-5 w-5" />,
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
trial: {
label: 'Trial & Onboarding',
icon: <Clock className="h-5 w-5" />,
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
subscription: {
label: 'Subscription Changes',
icon: <Package className="h-5 w-5" />,
color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
},
billing: {
label: 'Billing & Payments',
icon: <CreditCard className="h-5 w-5" />,
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
},
};
// Category order for display
const CATEGORY_ORDER: PlatformEmailCategory[] = [
'invitation',
'trial',
'subscription',
'billing',
];
const PlatformEmailTemplates: React.FC = () => {
const { t } = useTranslation();
const [expandedCategories, setExpandedCategories] = useState<Set<PlatformEmailCategory>>(
new Set(CATEGORY_ORDER)
);
const [editingTemplate, setEditingTemplate] = useState<PlatformEmailTemplateDetail | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewSubject, setPreviewSubject] = useState<string>('');
const [showResetConfirm, setShowResetConfirm] = useState<string | null>(null);
const [editorData, setEditorData] = useState<any>(null);
const [editorSubject, setEditorSubject] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Get email editor config
const editorConfig = getEmailEditorConfig();
// Fetch all email templates
const { data: templates = [], isLoading } = usePlatformEmailTemplates();
// Mutations
const updateMutation = useUpdatePlatformEmailTemplate();
const resetMutation = useResetPlatformEmailTemplate();
const previewMutation = usePreviewPlatformEmailTemplate();
// Fix Puck sidebar scrolling when editor is open
useEffect(() => {
if (!editingTemplate) return;
const fixSidebars = () => {
const leftSidebar = document.querySelector('[class*="Sidebar--left"]') as HTMLElement;
const rightSidebar = document.querySelector('[class*="Sidebar--right"]') as HTMLElement;
[leftSidebar, rightSidebar].forEach((sidebar) => {
if (!sidebar) return;
sidebar.style.maxHeight = 'calc(100vh - 300px)';
sidebar.style.overflowY = 'auto';
const innerSections = sidebar.querySelectorAll('[class*="SidebarSection"]');
innerSections.forEach((section) => {
(section as HTMLElement).style.overflow = 'visible';
(section as HTMLElement).style.maxHeight = 'none';
});
});
};
const timer = setTimeout(fixSidebars, 200);
return () => clearTimeout(timer);
}, [editingTemplate]);
// Fetch single template with tags
const fetchTemplateDetail = async (emailType: PlatformEmailType): Promise<PlatformEmailTemplateDetail> => {
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
};
// Group templates by category
const templatesByCategory = React.useMemo(() => {
const grouped: Record<PlatformEmailCategory, PlatformEmailTemplate[]> = {
invitation: [],
trial: [],
subscription: [],
billing: [],
};
templates.forEach((template) => {
if (grouped[template.category]) {
grouped[template.category].push(template);
}
});
return grouped;
}, [templates]);
// Toggle category expansion
const toggleCategory = (category: PlatformEmailCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
/**
* Transform puck_data to ensure id is inside props (Puck requirement).
*/
const transformPuckDataForEditor = (puckData: any): any => {
if (!puckData?.content) return puckData;
return {
...puckData,
content: puckData.content.map((item: any, index: number) => {
const rootId = item.id;
const hasPropsId = item.props?.id;
if (rootId && !hasPropsId) {
const { id, ...rest } = item;
return {
...rest,
props: {
...rest.props,
id: rootId,
},
};
} else if (!hasPropsId) {
return {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}
return item;
}),
};
};
// Open editor for a template
const handleEdit = async (template: PlatformEmailTemplate) => {
try {
const detail = await fetchTemplateDetail(template.email_type);
const transformedData = transformPuckDataForEditor(detail.puck_data);
setEditingTemplate(detail);
setEditorData(transformedData);
setEditorSubject(detail.subject_template);
setHasUnsavedChanges(false);
} catch (error) {
console.error('Failed to load template:', error);
}
};
// Handle data changes in editor
const handleEditorChange = useCallback((newData: any) => {
setEditorData(newData);
setHasUnsavedChanges(true);
}, []);
// Save template
const handleSave = async () => {
if (!editingTemplate) return;
try {
await updateMutation.mutateAsync({
emailType: editingTemplate.email_type,
data: {
subject_template: editorSubject,
puck_data: editorData,
},
});
setHasUnsavedChanges(false);
setEditingTemplate(null);
} catch (error: any) {
console.error('Failed to save template:', error);
const errorMsg = error?.response?.data?.subject_template?.[0] ||
error?.response?.data?.error ||
'Failed to save template';
alert(errorMsg);
}
};
// Preview template
const handlePreview = async () => {
if (!editingTemplate) return;
try {
const preview = await previewMutation.mutateAsync({
emailType: editingTemplate.email_type,
context: {},
});
setPreviewSubject(preview.subject);
setPreviewHtml(preview.html);
setShowPreviewModal(true);
} catch (error) {
console.error('Failed to generate preview:', error);
}
};
// Reset template to default
const handleReset = async (emailType: PlatformEmailType) => {
try {
await resetMutation.mutateAsync(emailType);
setShowResetConfirm(null);
} catch (error) {
console.error('Failed to reset template:', error);
}
};
// Close editor
const handleCloseEditor = () => {
if (hasUnsavedChanges) {
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
return;
}
}
setEditingTemplate(null);
setEditorData(null);
setEditorSubject('');
setHasUnsavedChanges(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Mail className="h-7 w-7 text-brand-600" />
{t('platform.emailTemplates.title', 'Platform Email Templates')}
</h1>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t(
'platform.emailTemplates.description',
'Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.'
)}
</p>
</div>
{/* Info Banner */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">About Template Tags</p>
<p>
Use template tags like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{{ tenant_name }}'}</code> to
insert dynamic content. Available tags vary by email type and are shown when editing each template.
</p>
</div>
</div>
</div>
{/* Template Categories */}
<div className="space-y-4">
{CATEGORY_ORDER.map((category) => {
const categoryTemplates = templatesByCategory[category];
const config = CATEGORY_CONFIG[category];
const isExpanded = expandedCategories.has(category);
if (categoryTemplates.length === 0) return null;
return (
<div
key={category}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`p-2 rounded-lg ${config.color}`}>{config.icon}</span>
<div className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white">{config.label}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{categoryTemplates.length} template{categoryTemplates.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-400" />
) : (
<ChevronRight className="h-5 w-5 text-gray-400" />
)}
</button>
{/* Template List */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
{categoryTemplates.map((template) => (
<div
key={template.email_type}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">
{template.display_name}
</h4>
{template.is_customized && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
Customized
</span>
)}
{!template.is_active && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Disabled
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{template.description}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Subject: <span className="font-mono">{template.subject_template}</span>
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{template.is_customized && (
<button
onClick={() => setShowResetConfirm(template.email_type)}
className="p-2 text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors"
title="Reset to default"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleEdit(template)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Edit2 className="h-4 w-4" />
Edit
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Reset to Default?
</h3>
</div>
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400">
This will reset the email template to its default content. Any customizations you've made will be lost.
</p>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => setShowResetConfirm(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={() => handleReset(showResetConfirm as PlatformEmailType)}
disabled={resetMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors font-medium"
>
{resetMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
Reset to Default
</button>
</div>
</div>
</div>
)}
{/* Editor Modal */}
{editingTemplate && (
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
{/* Editor Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={handleCloseEditor}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
<div>
<h2 className="font-semibold text-gray-900 dark:text-white">
{editingTemplate.display_name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{editingTemplate.description}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{hasUnsavedChanges && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
Unsaved changes
</span>
)}
<button
onClick={handlePreview}
disabled={previewMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={handleSave}
disabled={updateMutation.isPending || !hasUnsavedChanges}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save
</button>
</div>
</div>
{/* Subject Line Editor */}
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Subject
</label>
<input
type="text"
value={editorSubject}
onChange={(e) => {
setEditorSubject(e.target.value);
setHasUnsavedChanges(true);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Enter email subject..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Use tags like <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{ tenant_name }}'}</code> for dynamic content
</p>
</div>
{/* Available Tags Panel */}
<div className="bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
<details className="group" open>
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
<Code className="h-4 w-4" />
Available Template Tags ({editingTemplate.available_tags?.length || 0})
<ChevronRight className="h-4 w-4 group-open:rotate-90 transition-transform" />
</summary>
<div className="mt-3">
<div className="flex flex-wrap gap-1.5">
{(editingTemplate.available_tags || []).map((tag: PlatformEmailTag) => (
<span
key={tag.name}
className="inline-flex items-center px-2 py-0.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono cursor-help hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
title={tag.description}
onClick={() => {
navigator.clipboard.writeText(tag.syntax);
}}
>
{tag.name}
</span>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Click a tag to copy it. Hover for description.
</p>
</div>
</details>
</div>
{/* Puck Editor */}
<div className="flex-1 overflow-hidden">
{editorData && (
<Puck
key={`platform-email-puck-${editingTemplate?.email_type}`}
config={editorConfig}
data={editorData}
onChange={handleEditorChange}
onPublish={handleSave}
/>
)}
</div>
</div>
)}
{/* Preview Modal */}
{showPreviewModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<MonitorPlay className="h-5 w-5 text-brand-600" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Subject: {previewSubject}
</p>
</div>
</div>
<button
onClick={() => setShowPreviewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto p-6 bg-gray-50 dark:bg-gray-900">
<div className="bg-white rounded-lg shadow-sm overflow-hidden max-w-2xl mx-auto">
<iframe
srcDoc={previewHtml}
className="w-full h-[500px]"
title="Email Preview"
/>
</div>
</div>
{/* Preview Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => setShowPreviewModal(false)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PlatformEmailTemplates;

View File

@@ -17,10 +17,23 @@ import {
CheckCircle,
XCircle,
Loader2,
Send,
Clock,
RefreshCw,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { usePlatformUsers } from '../../hooks/usePlatform';
import { useCurrentUser } from '../../hooks/useAuth';
import EditPlatformUserModal from './components/EditPlatformUserModal';
import {
usePlatformStaffInvitations,
useCreatePlatformStaffInvitation,
useResendPlatformStaffInvitation,
useCancelPlatformStaffInvitation,
type PlatformStaffRole,
type PlatformStaffInvitation,
} from '../../hooks/usePlatformStaffInvitations';
interface PlatformUser {
id: number;
@@ -32,11 +45,6 @@ interface PlatformUser {
role: string;
is_active: boolean;
is_superuser: boolean;
permissions: {
can_approve_plugins?: boolean;
can_whitelist_urls?: boolean;
[key: string]: any;
};
created_at: string;
last_login?: string;
}
@@ -47,8 +55,23 @@ const PlatformStaff: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [showInvitations, setShowInvitations] = useState(false);
// Invitation form state
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<PlatformStaffRole>('platform_support');
const [inviteMessage, setInviteMessage] = useState('');
const { data: allUsers, isLoading, error } = usePlatformUsers();
const { data: invitations, isLoading: invitationsLoading } = usePlatformStaffInvitations();
const createInvitation = useCreatePlatformStaffInvitation();
const resendInvitation = useResendPlatformStaffInvitation();
const cancelInvitation = useCancelPlatformStaffInvitation();
const pendingInvitations = (invitations || []).filter(
(inv: PlatformStaffInvitation) => inv.status === 'PENDING'
);
// Filter to only show platform staff (not superusers, not business users)
const platformStaff = (allUsers || []).filter(
@@ -92,6 +115,51 @@ const PlatformStaff: React.FC = () => {
setIsEditModalOpen(true);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) {
toast.error('Email is required');
return;
}
try {
await createInvitation.mutateAsync({
email: inviteEmail.trim(),
role: inviteRole,
personal_message: inviteMessage.trim(),
});
toast.success('Invitation sent successfully!');
setIsInviteModalOpen(false);
resetInviteForm();
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to send invitation');
}
};
const handleResendInvitation = async (id: number) => {
try {
await resendInvitation.mutateAsync(id);
toast.success('Invitation resent');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to resend invitation');
}
};
const handleCancelInvitation = async (id: number) => {
try {
await cancelInvitation.mutateAsync(id);
toast.success('Invitation cancelled');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to cancel invitation');
}
};
const resetInviteForm = () => {
setInviteEmail('');
setInviteRole('platform_support');
setInviteMessage('');
};
const getRoleBadge = (role: string) => {
if (role === 'platform_manager') {
return (
@@ -156,13 +224,10 @@ const PlatformStaff: React.FC = () => {
</div>
<button
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
onClick={() => {
// TODO: Implement create new staff member
alert('Create new staff member - coming soon');
}}
onClick={() => setIsInviteModalOpen(true)}
>
<Plus className="w-4 h-4" />
Add Staff Member
Invite Staff Member
</button>
</div>
</div>
@@ -182,7 +247,7 @@ const PlatformStaff: React.FC = () => {
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">Total Staff</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
@@ -201,8 +266,75 @@ const PlatformStaff: React.FC = () => {
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
</div>
</div>
<button
onClick={() => setShowInvitations(!showInvitations)}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-left hover:border-indigo-300 dark:hover:border-indigo-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Pending Invitations</div>
{pendingInvitations.length > 0 && (
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
)}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{pendingInvitations.length}
</div>
</button>
</div>
{/* Pending Invitations */}
{showInvitations && pendingInvitations.length > 0 && (
<div className="mb-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<h3 className="text-lg font-medium text-amber-800 dark:text-amber-200 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Pending Invitations
</h3>
<div className="space-y-3">
{pendingInvitations.map((inv: PlatformStaffInvitation) => (
<div
key={inv.id}
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-4 border border-amber-200 dark:border-amber-800"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-white font-semibold">
<Mail className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{inv.email}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{inv.role_display} &bull; Invited by {inv.invited_by || 'Unknown'} &bull; Expires{' '}
{inv.expires_at
? new Date(inv.expires_at).toLocaleDateString()
: 'in 7 days'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleResendInvitation(inv.id)}
disabled={resendInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors disabled:opacity-50"
>
<RefreshCw className="w-3.5 h-3.5" />
Resend
</button>
<button
onClick={() => handleCancelInvitation(inv.id)}
disabled={cancelInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Staff List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<table className="w-full text-sm">
@@ -214,9 +346,6 @@ const PlatformStaff: React.FC = () => {
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Permissions
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
@@ -254,27 +383,6 @@ const PlatformStaff: React.FC = () => {
{/* Role */}
<td className="px-6 py-4">{getRoleBadge(user.role)}</td>
{/* Permissions */}
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{user.permissions?.can_approve_plugins && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Plugin Approver
</span>
)}
{user.permissions?.can_whitelist_urls && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
URL Whitelister
</span>
)}
{!user.permissions?.can_approve_plugins && !user.permissions?.can_whitelist_urls && (
<span className="text-xs text-gray-400 dark:text-gray-500">
No special permissions
</span>
)}
</div>
</td>
{/* Status */}
<td className="px-6 py-4">
{user.is_active ? (
@@ -341,6 +449,108 @@ const PlatformStaff: React.FC = () => {
user={selectedUser}
/>
)}
{/* Invite Modal */}
{isInviteModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Send className="w-5 h-5" />
Invite Staff Member
</h2>
<button
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<form onSubmit={handleInvite} className="p-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address <span className="text-red-500">*</span>
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="email@example.com"
required
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <span className="text-red-500">*</span>
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as PlatformStaffRole)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="platform_support">Platform Support</option>
<option value="platform_manager">Platform Manager</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{inviteRole === 'platform_manager'
? 'Managers can oversee platform operations and manage tenants.'
: 'Support staff can help users and respond to tickets.'}
</p>
</div>
{/* Personal Message */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Personal Message (Optional)
</label>
<textarea
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="Add a personal note to the invitation email..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createInvitation.isPending || !inviteEmail.trim()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createInvitation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Invitation
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<XCircle size={48} className="mx-auto text-red-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.invalidLink', 'Invalid Invitation Link')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.noToken',
'This invitation link is invalid. Please check your email for the correct link.'
)}
</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loader2 size={48} className="mx-auto text-indigo-600 animate-spin mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.loading', 'Loading invitation...')}
</p>
</div>
</div>
);
}
// Error state (invalid/expired token)
if (error || !invitation) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<AlertCircle size={48} className="mx-auto text-amber-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.expiredTitle', 'Invitation Expired or Invalid')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.expiredDescription',
'This invitation has expired or is no longer valid. Please contact the administrator to request a new invitation.'
)}
</p>
</div>
</div>
);
}
// Accepted state
if (accepted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<CheckCircle size={48} className="mx-auto text-green-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('platformStaffInvite.welcomeTitle', 'Welcome to the Platform Team!')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
</p>
<Loader2 size={24} className="mx-auto text-indigo-600 animate-spin mt-4" />
</div>
</div>
);
}
// Main acceptance form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 p-6 text-center">
<Shield size={40} className="mx-auto text-white/90 mb-2" />
<h1 className="text-2xl font-bold text-white mb-2">
{t('platformStaffInvite.title', 'Platform Staff Invitation')}
</h1>
<p className="text-indigo-100">
{t('platformStaffInvite.subtitle', 'Join the SmoothSchedule platform team')}
</p>
</div>
{/* Invitation Details */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
<Shield size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('platformStaffInvite.role', 'Role')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.role_display}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400">
<Mail size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('platformStaffInvite.email', 'Email')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.email}</p>
</div>
</div>
{invitation.role_description && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">{invitation.role_description}</p>
</div>
)}
{invitation.personal_message && (
<div className="mt-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p className="text-xs text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-1">
{t('platformStaffInvite.personalMessage', 'Personal Message')}
</p>
<p className="text-sm text-gray-700 dark:text-gray-300 italic">"{invitation.personal_message}"</p>
</div>
)}
{invitation.invited_by && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
{t('platformStaffInvite.invitedBy', 'Invited by')}{' '}
<span className="font-medium">{invitation.invited_by}</span>
</p>
)}
</div>
</div>
{/* Form */}
<form onSubmit={handleAccept} className="p-6 space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{t('platformStaffInvite.formDescription', 'Create your account to accept this invitation.')}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.firstName', 'First Name')} *
</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.lastName', 'Last Name')}
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.password', 'Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={10}
className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Min. 10 characters"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.confirmPassword', 'Confirm Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Repeat password"
/>
</div>
</div>
{/* Error Message */}
{formError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
</div>
)}
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={acceptMutation.isPending}
className="w-full px-4 py-3 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{acceptMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<CheckCircle size={18} />
)}
{t('platformStaffInvite.acceptButton', 'Accept Invitation & Create Account')}
</button>
</div>
</form>
</div>
</div>
);
};
export default PlatformStaffInvitePage;

View File

@@ -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<BusinessEditModalProps> = ({ 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<BusinessEditModalProps> = ({ 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<BusinessEditModalProps> = ({ 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<BusinessEditModalProps> = ({ 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<BusinessEditModalProps> = ({ business, isOpen,
if (!business || !billingFeatures) return;
try {
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
const featuresForBackend: Record<string, boolean | number | null> = {};
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<string, boolean | number | null> = {};
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<BusinessEditModalProps> = ({ business, isOpen,
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
Loading...
</span>
) : customTier ? (
) : useCustomFeatures ? (
<span className="flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
<AlertCircle size={12} />
Custom Tier
Custom Features
</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Plan Defaults
Plan Features
</span>
)}
</div>
@@ -374,24 +391,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
{/* Subscription Plan */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Subscription Plan
</label>
<button
type="button"
onClick={handleResetToPlanDefaults}
disabled={plansLoading || deletingCustomTier}
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 disabled:opacity-50"
>
<RefreshCw size={12} className={deletingCustomTier ? 'animate-spin' : ''} />
{deletingCustomTier
? 'Deleting...'
: customTier
? 'Delete custom tier & reset to plan defaults'
: 'Reset to plan defaults'}
</button>
</div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subscription Plan
</label>
<select
value={editForm.plan_code}
onChange={(e) => handlePlanChange(e.target.value)}
@@ -417,12 +419,34 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
)}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{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
</p>
</div>
{/* Custom Features Toggle */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<label className="text-sm font-medium text-gray-900 dark:text-white">
Enable Custom Features
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{useCustomFeatures
? 'Features below will be saved as a custom tier (overrides plan)'
: 'Features below are from the subscription plan (read-only)'}
</p>
</div>
<button
type="button"
onClick={() => handleCustomFeaturesToggle(!useCustomFeatures)}
disabled={loadingCustomTier || deletingCustomTier}
className={`${useCustomFeatures ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50`}
role="switch"
aria-checked={useCustomFeatures}
>
<span className={`${useCustomFeatures ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
{/* Limits & Quotas - Dynamic from billing system */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<DynamicFeaturesEditor
@@ -434,6 +458,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
disabled={!useCustomFeatures}
/>
</div>
@@ -468,6 +493,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
featureType="boolean"
headerTitle="Features & Permissions"
showDescriptions
disabled={!useCustomFeatures}
/>
</div>

View File

@@ -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<EditPlatformUserModalProps> = ({
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<EditPlatformUserModalProps> = ({
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<EditPlatformUserModalProps> = ({
},
});
// 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<EditPlatformUserModalProps> = ({
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<EditPlatformUserModalProps> = ({
// Prepare update data
const updateData: any = {
...formData,
permissions: permissions,
};
// Only include password if changed
@@ -148,13 +161,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
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<EditPlatformUserModalProps> = ({
</div>
</div>
{/* Permissions */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Special Permissions
</h4>
<div className="space-y-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{availablePermissions.can_approve_plugins && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_approve_plugins}
onChange={() => handlePermissionToggle('can_approve_plugins')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Approve Plugins
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to review and approve community plugins for the marketplace
</div>
</div>
</label>
)}
{availablePermissions.can_whitelist_urls && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_whitelist_urls}
onChange={() => handlePermissionToggle('can_whitelist_urls')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Whitelist URLs
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to whitelist external URLs for plugin API calls (per-user and platform-wide)
</div>
</div>
</label>
)}
{!availablePermissions.can_approve_plugins && !availablePermissions.can_whitelist_urls && (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
You don't have any special permissions to grant.
</p>
)}
</div>
</div>
{/* Password Reset */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -445,22 +401,95 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
)}
{/* Actions */}
<div className="mt-6 flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
<div className="mt-6 flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
{/* Left side - Destructive actions */}
<div className="flex items-center gap-2">
{canDelete && !showDeleteConfirm && !showArchiveConfirm && (
<>
<button
type="button"
onClick={() => setShowArchiveConfirm(true)}
disabled={!user.is_active}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={!user.is_active ? 'User is already archived' : 'Archive user'}
>
<Archive className="w-4 h-4" />
Archive
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</>
)}
{/* Archive confirmation */}
{showArchiveConfirm && (
<div className="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
<span className="text-sm text-amber-700 dark:text-amber-300">Archive this user?</span>
<button
type="button"
onClick={() => archiveMutation.mutate()}
disabled={archiveMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
>
{archiveMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Archive'}
</button>
<button
type="button"
onClick={() => setShowArchiveConfirm(false)}
className="px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
{/* Delete confirmation */}
{showDeleteConfirm && (
<div className="flex items-center gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2">
<span className="text-sm text-red-700 dark:text-red-300">Permanently delete?</span>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
{deleteMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Delete'}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
</div>
{/* Right side - Save/Cancel */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</form>
</div>

View File

@@ -186,17 +186,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ 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'),

View File

@@ -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(

View File

@@ -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)}`,
},
};
}

View File

@@ -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<string, any>;
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<string, any>;
is_active?: boolean;
}

View File

@@ -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

View File

@@ -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 |

View File

@@ -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'

View File

@@ -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

View File

@@ -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"),

View File

@@ -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/

View File

@@ -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},
],
},
]

View File

@@ -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')

View File

@@ -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')},
},
),
]

View File

@@ -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)'),
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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",
])

View File

@@ -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,
}

View File

@@ -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"

View File

@@ -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():
"""

View File

@@ -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

View File

@@ -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"

View File

@@ -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']

View File

@@ -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': '<strong>Appointments Used:</strong> {{ appointments_used }} of {{ appointments_limit }}<br><strong>Billing Period:</strong> {{ billing_period }}<br><strong>Remaining:</strong> {{ 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': '<strong>Storage Used:</strong> {{ storage_used }} of {{ storage_limit }}<br><strong>Billing Period:</strong> {{ billing_period }}<br><strong>Remaining:</strong> {{ 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': {}
}
},
}

View File

@@ -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