feat: Quota overage system, updated tier pricing, and communication addons
Quota Overage System: - Add QuotaOverage model for tracking resource/user quota overages - Implement 30-day grace period with email notifications (immediate, 7-day, 1-day) - Add QuotaWarningBanner component in BusinessLayout - Add QuotaSettings page for managing overages and archiving resources - Add Celery tasks for automated quota checks and expiration handling - Add quota management API endpoints Updated Tier Pricing (Stripe: 2.9% + $0.30): - Free: No payments (requires addon) - Starter: 4% + $0.40 - Professional: 3.5% + $0.35 - Business: 3.25% + $0.32 - Enterprise: 3% + $0.30 New Subscription Addons: - Online Payments ($5/mo + 5% + $0.50) - for Free tier - SMS Notifications ($10/mo) - enables SMS reminders - Masked Calling ($15/mo) - enables anonymous calling BusinessEditModal Improvements: - Increased width to match PlanModal (max-w-3xl) - Added all tier options with auto-update on tier change - Added limits configuration and permissions sections Backend Fixes: - Fixed SubscriptionPlan serializer to include all communication fields - Allow blank business_tier for addon plans - Added migration for business_tier field changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ const AuthenticationSettings = React.lazy(() => import('./pages/settings/Authent
|
|||||||
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
|
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
|
||||||
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||||
|
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||||
|
|
||||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||||
|
|
||||||
@@ -705,6 +706,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="email" element={<EmailSettings />} />
|
<Route path="email" element={<EmailSettings />} />
|
||||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||||
<Route path="billing" element={<BillingSettings />} />
|
<Route path="billing" element={<BillingSettings />} />
|
||||||
|
<Route path="quota" element={<QuotaSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
) : (
|
) : (
|
||||||
<Route path="/settings/*" element={<Navigate to="/" />} />
|
<Route path="/settings/*" element={<Navigate to="/" />} />
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ export interface LoginCredentials {
|
|||||||
|
|
||||||
import { UserRole } from '../types';
|
import { UserRole } from '../types';
|
||||||
|
|
||||||
|
export interface QuotaOverage {
|
||||||
|
id: number;
|
||||||
|
quota_type: string;
|
||||||
|
display_name: string;
|
||||||
|
current_usage: number;
|
||||||
|
allowed_limit: number;
|
||||||
|
overage_amount: number;
|
||||||
|
days_remaining: number;
|
||||||
|
grace_period_ends_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MasqueradeStackEntry {
|
export interface MasqueradeStackEntry {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -58,6 +69,10 @@ export interface User {
|
|||||||
business?: number;
|
business?: number;
|
||||||
business_name?: string;
|
business_name?: string;
|
||||||
business_subdomain?: string;
|
business_subdomain?: string;
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
|
can_invite_staff?: boolean;
|
||||||
|
can_access_tickets?: boolean;
|
||||||
|
quota_overages?: QuotaOverage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
103
frontend/src/api/quota.ts
Normal file
103
frontend/src/api/quota.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Quota Management API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
import { QuotaOverage } from './auth';
|
||||||
|
|
||||||
|
export interface QuotaUsage {
|
||||||
|
current: number;
|
||||||
|
limit: number;
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaStatus {
|
||||||
|
active_overages: QuotaOverage[];
|
||||||
|
usage: Record<string, QuotaUsage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaResource {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
type?: string;
|
||||||
|
duration?: number;
|
||||||
|
price?: string;
|
||||||
|
created_at: string | null;
|
||||||
|
is_archived: boolean;
|
||||||
|
archived_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaResourcesResponse {
|
||||||
|
quota_type: string;
|
||||||
|
resources: QuotaResource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveResponse {
|
||||||
|
archived_count: number;
|
||||||
|
current_usage: number;
|
||||||
|
limit: number;
|
||||||
|
is_resolved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaOverageDetail extends QuotaOverage {
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
initial_email_sent_at: string | null;
|
||||||
|
week_reminder_sent_at: string | null;
|
||||||
|
day_reminder_sent_at: string | null;
|
||||||
|
archived_resource_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current quota status
|
||||||
|
*/
|
||||||
|
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
|
||||||
|
const response = await apiClient.get<QuotaStatus>('/quota/status/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resources for a specific quota type
|
||||||
|
*/
|
||||||
|
export const getQuotaResources = async (quotaType: string): Promise<QuotaResourcesResponse> => {
|
||||||
|
const response = await apiClient.get<QuotaResourcesResponse>(`/quota/resources/${quotaType}/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive resources to resolve quota overage
|
||||||
|
*/
|
||||||
|
export const archiveResources = async (
|
||||||
|
quotaType: string,
|
||||||
|
resourceIds: number[]
|
||||||
|
): Promise<ArchiveResponse> => {
|
||||||
|
const response = await apiClient.post<ArchiveResponse>('/quota/archive/', {
|
||||||
|
quota_type: quotaType,
|
||||||
|
resource_ids: resourceIds,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unarchive a resource
|
||||||
|
*/
|
||||||
|
export const unarchiveResource = async (
|
||||||
|
quotaType: string,
|
||||||
|
resourceId: number
|
||||||
|
): Promise<{ success: boolean; resource_id: number }> => {
|
||||||
|
const response = await apiClient.post('/quota/unarchive/', {
|
||||||
|
quota_type: quotaType,
|
||||||
|
resource_id: resourceId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get details for a specific overage
|
||||||
|
*/
|
||||||
|
export const getOverageDetail = async (overageId: number): Promise<QuotaOverageDetail> => {
|
||||||
|
const response = await apiClient.get<QuotaOverageDetail>(`/quota/overages/${overageId}/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
131
frontend/src/components/QuotaWarningBanner.tsx
Normal file
131
frontend/src/components/QuotaWarningBanner.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AlertTriangle, X, ExternalLink } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { QuotaOverage } from '../api/auth';
|
||||||
|
|
||||||
|
interface QuotaWarningBannerProps {
|
||||||
|
overages: QuotaOverage[];
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDismiss }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!overages || overages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most urgent overage (least days remaining)
|
||||||
|
const mostUrgent = overages.reduce((prev, curr) =>
|
||||||
|
curr.days_remaining < prev.days_remaining ? curr : prev
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUrgent = mostUrgent.days_remaining <= 7;
|
||||||
|
const isCritical = mostUrgent.days_remaining <= 1;
|
||||||
|
|
||||||
|
const getBannerStyles = () => {
|
||||||
|
if (isCritical) {
|
||||||
|
return 'bg-red-600 text-white border-red-700';
|
||||||
|
}
|
||||||
|
if (isUrgent) {
|
||||||
|
return 'bg-amber-500 text-white border-amber-600';
|
||||||
|
}
|
||||||
|
return 'bg-amber-100 text-amber-900 border-amber-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconColor = () => {
|
||||||
|
if (isCritical || isUrgent) {
|
||||||
|
return 'text-white';
|
||||||
|
}
|
||||||
|
return 'text-amber-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border-b ${getBannerStyles()}`}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className={`h-5 w-5 flex-shrink-0 ${getIconColor()}`} />
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{isCritical
|
||||||
|
? t('quota.banner.critical', 'URGENT: Automatic archiving tomorrow!')
|
||||||
|
: isUrgent
|
||||||
|
? t('quota.banner.urgent', 'Action Required: {{days}} days left', { days: mostUrgent.days_remaining })
|
||||||
|
: t('quota.banner.warning', 'Quota exceeded for {{count}} item(s)', { count: overages.length })
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm opacity-90">
|
||||||
|
{t('quota.banner.details',
|
||||||
|
'You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.',
|
||||||
|
{
|
||||||
|
overage: mostUrgent.overage_amount,
|
||||||
|
type: mostUrgent.display_name,
|
||||||
|
date: formatDate(mostUrgent.grace_period_ends_at)
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/settings/quota"
|
||||||
|
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
isCritical || isUrgent
|
||||||
|
? 'bg-white/20 hover:bg-white/30 text-white'
|
||||||
|
: 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('quota.banner.manage', 'Manage Quota')}
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className={`p-1 rounded-md transition-colors ${
|
||||||
|
isCritical || isUrgent
|
||||||
|
? 'hover:bg-white/20'
|
||||||
|
: 'hover:bg-amber-200'
|
||||||
|
}`}
|
||||||
|
aria-label={t('common.dismiss', 'Dismiss')}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show additional overages if there are more than one */}
|
||||||
|
{overages.length > 1 && (
|
||||||
|
<div className="mt-2 text-sm opacity-90">
|
||||||
|
<span className="font-medium">{t('quota.banner.allOverages', 'All overages:')}</span>
|
||||||
|
<ul className="ml-4 mt-1 space-y-0.5">
|
||||||
|
{overages.map((overage) => (
|
||||||
|
<li key={overage.id}>
|
||||||
|
{overage.display_name}: {overage.current_usage}/{overage.allowed_limit}
|
||||||
|
({t('quota.banner.overBy', 'over by {{amount}}', { amount: overage.overage_amount })})
|
||||||
|
{' - '}
|
||||||
|
{overage.days_remaining <= 0
|
||||||
|
? t('quota.banner.expiredToday', 'expires today!')
|
||||||
|
: t('quota.banner.daysLeft', '{{days}} days left', { days: overage.days_remaining })
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuotaWarningBanner;
|
||||||
@@ -543,7 +543,11 @@
|
|||||||
"acceptPayments": "Accept Payments",
|
"acceptPayments": "Accept Payments",
|
||||||
"acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.",
|
"acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.",
|
||||||
"stripeSetupRequired": "Stripe Connect Setup Required",
|
"stripeSetupRequired": "Stripe Connect Setup Required",
|
||||||
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started."
|
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started.",
|
||||||
|
"quota": {
|
||||||
|
"title": "Quota Management",
|
||||||
|
"description": "Usage limits, archiving"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Profile Settings",
|
"title": "Profile Settings",
|
||||||
@@ -1051,6 +1055,36 @@
|
|||||||
"dataRetention": "Your data is safe and will be retained for 30 days."
|
"dataRetention": "Your data is safe and will be retained for 30 days."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"quota": {
|
||||||
|
"banner": {
|
||||||
|
"critical": "URGENT: Automatic archiving tomorrow!",
|
||||||
|
"urgent": "Action Required: {{days}} days left",
|
||||||
|
"warning": "Quota exceeded for {{count}} item(s)",
|
||||||
|
"details": "You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.",
|
||||||
|
"manage": "Manage Quota",
|
||||||
|
"allOverages": "All overages:",
|
||||||
|
"overBy": "over by {{amount}}",
|
||||||
|
"expiredToday": "expires today!",
|
||||||
|
"daysLeft": "{{days}} days left"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"title": "Quota Management",
|
||||||
|
"subtitle": "Manage your account limits and usage",
|
||||||
|
"currentUsage": "Current Usage",
|
||||||
|
"planLimit": "Plan Limit",
|
||||||
|
"overBy": "Over Limit By",
|
||||||
|
"gracePeriodEnds": "Grace Period Ends",
|
||||||
|
"daysRemaining": "{{days}} days remaining",
|
||||||
|
"selectToArchive": "Select items to archive",
|
||||||
|
"archiveSelected": "Archive Selected",
|
||||||
|
"upgradeInstead": "Upgrade Plan Instead",
|
||||||
|
"exportData": "Export Data",
|
||||||
|
"archiveWarning": "Archived items will become read-only and cannot be used for new bookings.",
|
||||||
|
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
|
||||||
|
"noOverages": "You are within your plan limits.",
|
||||||
|
"resolved": "Resolved! Your usage is now within limits."
|
||||||
|
}
|
||||||
|
},
|
||||||
"upgrade": {
|
"upgrade": {
|
||||||
"title": "Upgrade Your Plan",
|
"title": "Upgrade Your Plan",
|
||||||
"subtitle": "Choose the perfect plan for {{businessName}}",
|
"subtitle": "Choose the perfect plan for {{businessName}}",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Sidebar from '../components/Sidebar';
|
|||||||
import TopBar from '../components/TopBar';
|
import TopBar from '../components/TopBar';
|
||||||
import TrialBanner from '../components/TrialBanner';
|
import TrialBanner from '../components/TrialBanner';
|
||||||
import SandboxBanner from '../components/SandboxBanner';
|
import SandboxBanner from '../components/SandboxBanner';
|
||||||
|
import QuotaWarningBanner from '../components/QuotaWarningBanner';
|
||||||
import { Business, User } from '../types';
|
import { Business, User } from '../types';
|
||||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||||
import OnboardingWizard from '../components/OnboardingWizard';
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
@@ -283,6 +284,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
|||||||
onStop={handleStopMasquerade}
|
onStop={handleStopMasquerade}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Quota overage warning banner - show for owners and managers */}
|
||||||
|
{user.quota_overages && user.quota_overages.length > 0 && (
|
||||||
|
<QuotaWarningBanner overages={user.quota_overages} />
|
||||||
|
)}
|
||||||
{/* Sandbox mode banner */}
|
{/* Sandbox mode banner */}
|
||||||
<SandboxBannerWrapper />
|
<SandboxBannerWrapper />
|
||||||
{/* Show trial banner if trial is active and payments not yet enabled */}
|
{/* Show trial banner if trial is active and payments not yet enabled */}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SettingsSidebarSection,
|
SettingsSidebarSection,
|
||||||
@@ -137,6 +138,12 @@ const SettingsLayout: React.FC = () => {
|
|||||||
label={t('settings.billing.title', 'Plan & Billing')}
|
label={t('settings.billing.title', 'Plan & Billing')}
|
||||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||||
/>
|
/>
|
||||||
|
<SettingsSidebarItem
|
||||||
|
to="/settings/quota"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label={t('settings.quota.title', 'Quota Management')}
|
||||||
|
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||||
|
/>
|
||||||
</SettingsSidebarSection>
|
</SettingsSidebarSection>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -785,12 +785,14 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
|||||||
can_api_access: false,
|
can_api_access: false,
|
||||||
can_use_masked_phone_numbers: false,
|
can_use_masked_phone_numbers: false,
|
||||||
},
|
},
|
||||||
|
// Default transaction fees: Stripe charges 2.9% + $0.30, so we need to charge more
|
||||||
|
// Recommended: FREE 5%+50¢, STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢
|
||||||
transaction_fee_percent: plan?.transaction_fee_percent
|
transaction_fee_percent: plan?.transaction_fee_percent
|
||||||
? parseFloat(plan.transaction_fee_percent)
|
? parseFloat(plan.transaction_fee_percent)
|
||||||
: 0,
|
: 4.0, // Default 4% for new plans
|
||||||
transaction_fee_fixed: plan?.transaction_fee_fixed
|
transaction_fee_fixed: plan?.transaction_fee_fixed
|
||||||
? parseFloat(plan.transaction_fee_fixed)
|
? parseFloat(plan.transaction_fee_fixed)
|
||||||
: 0,
|
: 40, // Default 40 cents for new plans
|
||||||
// Communication pricing
|
// Communication pricing
|
||||||
sms_enabled: plan?.sms_enabled ?? false,
|
sms_enabled: plan?.sms_enabled ?? false,
|
||||||
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
|
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
|
||||||
@@ -1048,6 +1050,9 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
||||||
|
Stripe charges 2.9% + $0.30 per transaction + $2/mo per connected account. Recommended: STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢. Consider a payments add-on ($5/mo) for FREE tier users.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Communication Pricing */}
|
{/* Communication Pricing */}
|
||||||
@@ -1258,7 +1263,7 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
|||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
Max Users
|
Max Additional Users
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Save, Key } from 'lucide-react';
|
import { X, Save, Key, RefreshCw } from 'lucide-react';
|
||||||
import { useUpdateBusiness } from '../../../hooks/usePlatform';
|
import { useUpdateBusiness } from '../../../hooks/usePlatform';
|
||||||
|
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
|
||||||
import { PlatformBusiness } from '../../../api/platform';
|
import { PlatformBusiness } from '../../../api/platform';
|
||||||
|
|
||||||
|
// Default tier settings - used when no subscription plans are loaded
|
||||||
|
const TIER_DEFAULTS: Record<string, {
|
||||||
|
max_users: number;
|
||||||
|
max_resources: number;
|
||||||
|
can_manage_oauth_credentials: boolean;
|
||||||
|
can_accept_payments: boolean;
|
||||||
|
can_use_custom_domain: boolean;
|
||||||
|
can_white_label: boolean;
|
||||||
|
can_api_access: boolean;
|
||||||
|
}> = {
|
||||||
|
FREE: {
|
||||||
|
max_users: 2,
|
||||||
|
max_resources: 5,
|
||||||
|
can_manage_oauth_credentials: false,
|
||||||
|
can_accept_payments: false,
|
||||||
|
can_use_custom_domain: false,
|
||||||
|
can_white_label: false,
|
||||||
|
can_api_access: false,
|
||||||
|
},
|
||||||
|
STARTER: {
|
||||||
|
max_users: 5,
|
||||||
|
max_resources: 15,
|
||||||
|
can_manage_oauth_credentials: false,
|
||||||
|
can_accept_payments: true,
|
||||||
|
can_use_custom_domain: false,
|
||||||
|
can_white_label: false,
|
||||||
|
can_api_access: false,
|
||||||
|
},
|
||||||
|
PROFESSIONAL: {
|
||||||
|
max_users: 15,
|
||||||
|
max_resources: 50,
|
||||||
|
can_manage_oauth_credentials: false,
|
||||||
|
can_accept_payments: true,
|
||||||
|
can_use_custom_domain: true,
|
||||||
|
can_white_label: false,
|
||||||
|
can_api_access: true,
|
||||||
|
},
|
||||||
|
ENTERPRISE: {
|
||||||
|
max_users: -1, // unlimited
|
||||||
|
max_resources: -1, // unlimited
|
||||||
|
can_manage_oauth_credentials: true,
|
||||||
|
can_accept_payments: true,
|
||||||
|
can_use_custom_domain: true,
|
||||||
|
can_white_label: true,
|
||||||
|
can_api_access: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface BusinessEditModalProps {
|
interface BusinessEditModalProps {
|
||||||
business: PlatformBusiness | null;
|
business: PlatformBusiness | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -11,34 +60,144 @@ interface BusinessEditModalProps {
|
|||||||
|
|
||||||
const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => {
|
const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => {
|
||||||
const updateBusinessMutation = useUpdateBusiness();
|
const updateBusinessMutation = useUpdateBusiness();
|
||||||
|
const { data: subscriptionPlans } = useSubscriptionPlans();
|
||||||
|
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
is_active: true,
|
is_active: true,
|
||||||
subscription_tier: 'FREE',
|
subscription_tier: 'FREE',
|
||||||
|
// Limits
|
||||||
max_users: 5,
|
max_users: 5,
|
||||||
max_resources: 10,
|
max_resources: 10,
|
||||||
|
max_services: 0,
|
||||||
|
max_appointments: 0,
|
||||||
|
max_email_templates: 0,
|
||||||
|
max_automated_tasks: 0,
|
||||||
|
// Platform Permissions
|
||||||
can_manage_oauth_credentials: false,
|
can_manage_oauth_credentials: false,
|
||||||
can_accept_payments: false,
|
can_accept_payments: false,
|
||||||
can_use_custom_domain: false,
|
can_use_custom_domain: false,
|
||||||
can_white_label: false,
|
can_white_label: false,
|
||||||
can_api_access: false,
|
can_api_access: false,
|
||||||
// New feature limits (not yet implemented)
|
// Extended Permissions
|
||||||
limits: {
|
permissions: {
|
||||||
can_add_video_conferencing: false,
|
// Payments & Revenue
|
||||||
max_event_types: null as number | null,
|
can_process_refunds: false,
|
||||||
max_calendars_connected: null as number | null,
|
can_create_packages: false,
|
||||||
can_connect_to_api: false,
|
// Communication
|
||||||
can_book_repeated_events: false,
|
sms_reminders: false,
|
||||||
can_require_2fa: false,
|
|
||||||
can_download_logs: false,
|
|
||||||
can_delete_data: false,
|
|
||||||
can_use_masked_phone_numbers: false,
|
can_use_masked_phone_numbers: false,
|
||||||
can_use_pos: false,
|
can_use_email_templates: false,
|
||||||
can_use_mobile_app: false,
|
// Customization
|
||||||
|
can_customize_booking_page: false,
|
||||||
|
// Advanced Features
|
||||||
|
advanced_reporting: false,
|
||||||
|
can_create_plugins: false,
|
||||||
|
can_export_data: false,
|
||||||
|
can_use_webhooks: false,
|
||||||
|
calendar_sync: false,
|
||||||
|
// Support & Enterprise
|
||||||
|
priority_support: false,
|
||||||
|
dedicated_support: false,
|
||||||
|
sso_enabled: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get tier defaults from subscription plans or fallback to static defaults
|
||||||
|
const getTierDefaults = (tier: string) => {
|
||||||
|
// Try to find matching subscription plan
|
||||||
|
if (subscriptionPlans) {
|
||||||
|
const tierNameMap: Record<string, string> = {
|
||||||
|
'FREE': 'Free',
|
||||||
|
'STARTER': 'Starter',
|
||||||
|
'PROFESSIONAL': 'Professional',
|
||||||
|
'ENTERPRISE': 'Enterprise',
|
||||||
|
};
|
||||||
|
const plan = subscriptionPlans.find(p =>
|
||||||
|
p.business_tier === tierNameMap[tier] || p.business_tier === tier
|
||||||
|
);
|
||||||
|
if (plan) {
|
||||||
|
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
|
||||||
|
return {
|
||||||
|
// Limits
|
||||||
|
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
|
||||||
|
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
|
||||||
|
max_services: plan.limits?.max_services ?? 0,
|
||||||
|
max_appointments: plan.limits?.max_appointments ?? 0,
|
||||||
|
max_email_templates: plan.limits?.max_email_templates ?? 0,
|
||||||
|
max_automated_tasks: plan.limits?.max_automated_tasks ?? 0,
|
||||||
|
// Platform Permissions
|
||||||
|
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
|
||||||
|
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
|
||||||
|
can_use_custom_domain: plan.permissions?.can_use_custom_domain ?? staticDefaults.can_use_custom_domain,
|
||||||
|
can_white_label: plan.permissions?.can_white_label ?? staticDefaults.can_white_label,
|
||||||
|
can_api_access: plan.permissions?.can_api_access ?? staticDefaults.can_api_access,
|
||||||
|
// Extended Permissions
|
||||||
|
permissions: {
|
||||||
|
can_process_refunds: plan.permissions?.can_process_refunds ?? false,
|
||||||
|
can_create_packages: plan.permissions?.can_create_packages ?? false,
|
||||||
|
sms_reminders: plan.permissions?.sms_reminders ?? false,
|
||||||
|
can_use_masked_phone_numbers: plan.permissions?.can_use_masked_phone_numbers ?? false,
|
||||||
|
can_use_email_templates: plan.permissions?.can_use_email_templates ?? false,
|
||||||
|
can_customize_booking_page: plan.permissions?.can_customize_booking_page ?? false,
|
||||||
|
advanced_reporting: plan.permissions?.advanced_reporting ?? false,
|
||||||
|
can_create_plugins: plan.permissions?.can_create_plugins ?? false,
|
||||||
|
can_export_data: plan.permissions?.can_export_data ?? false,
|
||||||
|
can_use_webhooks: plan.permissions?.can_use_webhooks ?? false,
|
||||||
|
calendar_sync: plan.permissions?.calendar_sync ?? false,
|
||||||
|
priority_support: plan.permissions?.priority_support ?? false,
|
||||||
|
dedicated_support: plan.permissions?.dedicated_support ?? false,
|
||||||
|
sso_enabled: plan.permissions?.sso_enabled ?? false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to static defaults
|
||||||
|
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
|
||||||
|
return {
|
||||||
|
...staticDefaults,
|
||||||
|
max_services: 0,
|
||||||
|
max_appointments: 0,
|
||||||
|
max_email_templates: 0,
|
||||||
|
max_automated_tasks: 0,
|
||||||
|
permissions: {
|
||||||
|
can_process_refunds: false,
|
||||||
|
can_create_packages: false,
|
||||||
|
sms_reminders: false,
|
||||||
|
can_use_masked_phone_numbers: false,
|
||||||
|
can_use_email_templates: false,
|
||||||
|
can_customize_booking_page: false,
|
||||||
|
advanced_reporting: false,
|
||||||
|
can_create_plugins: false,
|
||||||
|
can_export_data: false,
|
||||||
|
can_use_webhooks: false,
|
||||||
|
calendar_sync: false,
|
||||||
|
priority_support: false,
|
||||||
|
dedicated_support: false,
|
||||||
|
sso_enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle subscription tier change - auto-update limits and permissions
|
||||||
|
const handleTierChange = (newTier: string) => {
|
||||||
|
const defaults = getTierDefaults(newTier);
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
subscription_tier: newTier,
|
||||||
|
...defaults,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset to tier defaults button handler
|
||||||
|
const handleResetToTierDefaults = () => {
|
||||||
|
const defaults = getTierDefaults(editForm.subscription_tier);
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
...defaults,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// Update form when business changes
|
// Update form when business changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (business) {
|
if (business) {
|
||||||
@@ -46,30 +205,51 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
name: business.name,
|
name: business.name,
|
||||||
is_active: business.is_active,
|
is_active: business.is_active,
|
||||||
subscription_tier: business.tier,
|
subscription_tier: business.tier,
|
||||||
|
// Limits
|
||||||
max_users: business.max_users || 5,
|
max_users: business.max_users || 5,
|
||||||
max_resources: business.max_resources || 10,
|
max_resources: business.max_resources || 10,
|
||||||
|
max_services: (business as any).max_services || 0,
|
||||||
|
max_appointments: (business as any).max_appointments || 0,
|
||||||
|
max_email_templates: (business as any).max_email_templates || 0,
|
||||||
|
max_automated_tasks: (business as any).max_automated_tasks || 0,
|
||||||
|
// Platform Permissions
|
||||||
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
||||||
can_accept_payments: business.can_accept_payments || false,
|
can_accept_payments: business.can_accept_payments || false,
|
||||||
can_use_custom_domain: business.can_use_custom_domain || false,
|
can_use_custom_domain: business.can_use_custom_domain || false,
|
||||||
can_white_label: business.can_white_label || false,
|
can_white_label: business.can_white_label || false,
|
||||||
can_api_access: business.can_api_access || false,
|
can_api_access: business.can_api_access || false,
|
||||||
limits: {
|
// Extended Permissions
|
||||||
can_add_video_conferencing: false,
|
permissions: {
|
||||||
max_event_types: null,
|
can_process_refunds: (business as any).permissions?.can_process_refunds || false,
|
||||||
max_calendars_connected: null,
|
can_create_packages: (business as any).permissions?.can_create_packages || false,
|
||||||
can_connect_to_api: false,
|
sms_reminders: (business as any).permissions?.sms_reminders || false,
|
||||||
can_book_repeated_events: false,
|
can_use_masked_phone_numbers: (business as any).permissions?.can_use_masked_phone_numbers || false,
|
||||||
can_require_2fa: false,
|
can_use_email_templates: (business as any).permissions?.can_use_email_templates || false,
|
||||||
can_download_logs: false,
|
can_customize_booking_page: (business as any).permissions?.can_customize_booking_page || false,
|
||||||
can_delete_data: false,
|
advanced_reporting: (business as any).permissions?.advanced_reporting || false,
|
||||||
can_use_masked_phone_numbers: false,
|
can_create_plugins: (business as any).permissions?.can_create_plugins || false,
|
||||||
can_use_pos: false,
|
can_export_data: (business as any).permissions?.can_export_data || false,
|
||||||
can_use_mobile_app: false,
|
can_use_webhooks: (business as any).permissions?.can_use_webhooks || false,
|
||||||
|
calendar_sync: (business as any).permissions?.calendar_sync || false,
|
||||||
|
priority_support: (business as any).permissions?.priority_support || false,
|
||||||
|
dedicated_support: (business as any).permissions?.dedicated_support || false,
|
||||||
|
sso_enabled: (business as any).permissions?.sso_enabled || false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [business]);
|
}, [business]);
|
||||||
|
|
||||||
|
// Helper for permission changes
|
||||||
|
const handlePermissionChange = (key: string, value: boolean) => {
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
permissions: {
|
||||||
|
...prev.permissions,
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditSave = () => {
|
const handleEditSave = () => {
|
||||||
if (!business) return;
|
if (!business) return;
|
||||||
|
|
||||||
@@ -90,7 +270,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -141,12 +321,22 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
|
|
||||||
{/* Subscription Tier */}
|
{/* Subscription Tier */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
Subscription Tier
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
</label>
|
Subscription Tier
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetToTierDefaults}
|
||||||
|
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} />
|
||||||
|
Reset to tier defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={editForm.subscription_tier}
|
value={editForm.subscription_tier}
|
||||||
onChange={(e) => setEditForm({ ...editForm, subscription_tier: e.target.value })}
|
onChange={(e) => handleTierChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="FREE">Free Trial</option>
|
<option value="FREE">Free Trial</option>
|
||||||
@@ -154,303 +344,312 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
|||||||
<option value="PROFESSIONAL">Professional</option>
|
<option value="PROFESSIONAL">Professional</option>
|
||||||
<option value="ENTERPRISE">Enterprise</option>
|
<option value="ENTERPRISE">Enterprise</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Changing tier will auto-update limits and permissions to tier defaults
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Limits */}
|
{/* Limits Configuration */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<div>
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
Limits Configuration
|
||||||
Max Users
|
</h3>
|
||||||
</label>
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
<input
|
Use -1 for unlimited. These limits control what this business can create.
|
||||||
type="number"
|
</p>
|
||||||
min="1"
|
<div className="grid grid-cols-3 gap-4">
|
||||||
value={editForm.max_users}
|
<div>
|
||||||
onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 1 })}
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
Max Users
|
||||||
/>
|
</label>
|
||||||
</div>
|
<input
|
||||||
<div>
|
type="number"
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
min="-1"
|
||||||
Max Resources
|
value={editForm.max_users}
|
||||||
</label>
|
onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 0 })}
|
||||||
<input
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
type="number"
|
/>
|
||||||
min="1"
|
</div>
|
||||||
value={editForm.max_resources}
|
<div>
|
||||||
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 1 })}
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
Max Resources
|
||||||
/>
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
value={editForm.max_resources}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Max Services
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
value={editForm.max_services}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, max_services: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Max Appointments / Month
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
value={editForm.max_appointments}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, max_appointments: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Max Email Templates
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
value={editForm.max_email_templates}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, max_email_templates: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Max Automated Tasks
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="-1"
|
||||||
|
value={editForm.max_automated_tasks}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, max_automated_tasks: parseInt(e.target.value) || 0 })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permissions Section */}
|
{/* Features & Permissions */}
|
||||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<Key size={16} className="text-purple-500" />
|
<Key size={16} className="text-purple-500" />
|
||||||
Platform Permissions
|
Features & Permissions
|
||||||
</h4>
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Control which features are available to this business.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{/* Payments & Revenue */}
|
||||||
{/* Can Manage OAuth Credentials */}
|
<div>
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
Manage OAuth Credentials
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
checked={editForm.can_accept_payments}
|
||||||
Allow this business to configure their own OAuth app credentials
|
onChange={(e) => setEditForm({ ...editForm, can_accept_payments: e.target.checked })}
|
||||||
</p>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
</div>
|
/>
|
||||||
<button
|
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
|
||||||
type="button"
|
</label>
|
||||||
onClick={() => setEditForm({ ...editForm, can_manage_oauth_credentials: !editForm.can_manage_oauth_credentials })}
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
className={`${editForm.can_manage_oauth_credentials ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
|
<input
|
||||||
role="switch"
|
type="checkbox"
|
||||||
>
|
checked={editForm.permissions.can_process_refunds}
|
||||||
<span className={`${editForm.can_manage_oauth_credentials ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
|
||||||
</button>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.can_create_packages}
|
||||||
|
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Can Accept Payments */}
|
{/* Communication */}
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
<div>
|
||||||
<div>
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
Accept Online Payments
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
</label>
|
<input
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
type="checkbox"
|
||||||
Enable Stripe Connect for payment processing
|
checked={editForm.permissions.sms_reminders}
|
||||||
</p>
|
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
|
||||||
</div>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||||
onClick={() => setEditForm({ ...editForm, can_accept_payments: !editForm.can_accept_payments })}
|
</label>
|
||||||
className={`${editForm.can_accept_payments ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
role="switch"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<span className={`${editForm.can_accept_payments ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
checked={editForm.permissions.can_use_masked_phone_numbers}
|
||||||
</button>
|
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.can_use_email_templates}
|
||||||
|
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Can Use Custom Domain */}
|
{/* Customization */}
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
<div>
|
||||||
<div>
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
Use Custom Domain
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
</label>
|
<input
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
type="checkbox"
|
||||||
Allow custom domain configuration
|
checked={editForm.permissions.can_customize_booking_page}
|
||||||
</p>
|
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
|
||||||
</div>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
|
||||||
onClick={() => setEditForm({ ...editForm, can_use_custom_domain: !editForm.can_use_custom_domain })}
|
</label>
|
||||||
className={`${editForm.can_use_custom_domain ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
role="switch"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<span className={`${editForm.can_use_custom_domain ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
checked={editForm.can_use_custom_domain}
|
||||||
</button>
|
onChange={(e) => setEditForm({ ...editForm, can_use_custom_domain: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.can_white_label}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, can_white_label: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Can White Label */}
|
{/* Advanced Features */}
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
<div>
|
||||||
<div>
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
Remove Branding
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
</label>
|
<input
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
type="checkbox"
|
||||||
Allow removal of SmoothSchedule branding
|
checked={editForm.permissions.advanced_reporting}
|
||||||
</p>
|
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
|
||||||
</div>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
|
||||||
onClick={() => setEditForm({ ...editForm, can_white_label: !editForm.can_white_label })}
|
</label>
|
||||||
className={`${editForm.can_white_label ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
role="switch"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<span className={`${editForm.can_white_label ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
checked={editForm.can_api_access}
|
||||||
</button>
|
onChange={(e) => setEditForm({ ...editForm, can_api_access: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.can_create_plugins}
|
||||||
|
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.can_export_data}
|
||||||
|
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.can_use_webhooks}
|
||||||
|
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.calendar_sync}
|
||||||
|
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Can API Access */}
|
{/* Support & Enterprise */}
|
||||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
<div>
|
||||||
<div>
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
API Access
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
</label>
|
<input
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
type="checkbox"
|
||||||
Enable API access for integrations
|
checked={editForm.can_manage_oauth_credentials}
|
||||||
</p>
|
onChange={(e) => setEditForm({ ...editForm, can_manage_oauth_credentials: e.target.checked })}
|
||||||
</div>
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
<button
|
/>
|
||||||
type="button"
|
<span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
|
||||||
onClick={() => setEditForm({ ...editForm, can_api_access: !editForm.can_api_access })}
|
</label>
|
||||||
className={`${editForm.can_api_access ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500`}
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
role="switch"
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<span className={`${editForm.can_api_access ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
checked={editForm.permissions.priority_support}
|
||||||
</button>
|
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.dedicated_support}
|
||||||
|
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editForm.permissions.sso_enabled}
|
||||||
|
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
|
||||||
|
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature Limits (Not Yet Implemented) */}
|
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Feature Limits & Capabilities
|
|
||||||
</label>
|
|
||||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
|
||||||
Coming Soon
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 opacity-50">
|
|
||||||
{/* Video Conferencing */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_add_video_conferencing}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can add video conferencing to events
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Event Types Limit */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.max_event_types === null}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited event types</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
disabled
|
|
||||||
value={editForm.limits.max_event_types || ''}
|
|
||||||
placeholder="Or set a limit"
|
|
||||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Calendars Connected Limit */}
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.max_calendars_connected === null}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited calendar connections</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
disabled
|
|
||||||
value={editForm.limits.max_calendars_connected || ''}
|
|
||||||
placeholder="Or set a limit"
|
|
||||||
className="mt-1 w-32 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* API Access */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_connect_to_api}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can connect to external APIs
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Repeated Events */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_book_repeated_events}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can book repeated/recurring events
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* 2FA */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_require_2fa}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can require 2FA for users
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Download Logs */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_download_logs}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can download system logs
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Delete Data */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_delete_data}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can permanently delete data
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Masked Phone Numbers */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_use_masked_phone_numbers}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can use masked phone numbers for privacy
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* POS Integration */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_use_pos}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can use Point of Sale (POS) system
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Mobile App */}
|
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editForm.limits.can_use_mobile_app}
|
|
||||||
disabled
|
|
||||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
Can use mobile app
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
|
|||||||
467
frontend/src/pages/settings/QuotaSettings.tsx
Normal file
467
frontend/src/pages/settings/QuotaSettings.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* Quota Settings Page
|
||||||
|
*
|
||||||
|
* Manage quota overages by selecting which resources to archive.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AlertTriangle, Archive, Check, ChevronDown, ChevronUp,
|
||||||
|
Clock, Download, Users, Briefcase, Calendar, RefreshCw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Business, User, QuotaOverage } from '../../types';
|
||||||
|
import {
|
||||||
|
getQuotaStatus,
|
||||||
|
getQuotaResources,
|
||||||
|
archiveResources,
|
||||||
|
QuotaStatus,
|
||||||
|
QuotaResource
|
||||||
|
} from '../../api/quota';
|
||||||
|
|
||||||
|
const QuotaSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { business, user } = useOutletContext<{
|
||||||
|
business: Business;
|
||||||
|
user: User;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [quotaStatus, setQuotaStatus] = useState<QuotaStatus | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedOverage, setExpandedOverage] = useState<number | null>(null);
|
||||||
|
const [resources, setResources] = useState<Record<string, QuotaResource[]>>({});
|
||||||
|
const [selectedResources, setSelectedResources] = useState<Record<string, Set<number>>>({});
|
||||||
|
const [archiving, setArchiving] = useState(false);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isOwner = user.role === 'owner';
|
||||||
|
const isManager = user.role === 'manager';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuotaStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadQuotaStatus = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const status = await getQuotaStatus();
|
||||||
|
setQuotaStatus(status);
|
||||||
|
|
||||||
|
// Auto-expand first overage if any
|
||||||
|
if (status.active_overages.length > 0) {
|
||||||
|
setExpandedOverage(status.active_overages[0].id);
|
||||||
|
await loadResources(status.active_overages[0].quota_type);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load quota status');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadResources = async (quotaType: string) => {
|
||||||
|
if (resources[quotaType]) return; // Already loaded
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getQuotaResources(quotaType);
|
||||||
|
setResources(prev => ({
|
||||||
|
...prev,
|
||||||
|
[quotaType]: response.resources
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load resources:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOverage = async (overage: QuotaOverage) => {
|
||||||
|
if (expandedOverage === overage.id) {
|
||||||
|
setExpandedOverage(null);
|
||||||
|
} else {
|
||||||
|
setExpandedOverage(overage.id);
|
||||||
|
await loadResources(overage.quota_type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleResourceSelection = (quotaType: string, resourceId: number) => {
|
||||||
|
setSelectedResources(prev => {
|
||||||
|
const current = prev[quotaType] || new Set();
|
||||||
|
const newSet = new Set(current);
|
||||||
|
if (newSet.has(resourceId)) {
|
||||||
|
newSet.delete(resourceId);
|
||||||
|
} else {
|
||||||
|
newSet.add(resourceId);
|
||||||
|
}
|
||||||
|
return { ...prev, [quotaType]: newSet };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (quotaType: string) => {
|
||||||
|
const selected = selectedResources[quotaType];
|
||||||
|
if (!selected || selected.size === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setArchiving(true);
|
||||||
|
const result = await archiveResources(quotaType, Array.from(selected));
|
||||||
|
|
||||||
|
// Clear selection and reload
|
||||||
|
setSelectedResources(prev => ({ ...prev, [quotaType]: new Set() }));
|
||||||
|
setResources(prev => {
|
||||||
|
const { [quotaType]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.is_resolved) {
|
||||||
|
setSuccessMessage(t('quota.page.resolved', 'Resolved! Your usage is now within limits.'));
|
||||||
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadQuotaStatus();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to archive resources');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setArchiving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getQuotaIcon = (quotaType: string) => {
|
||||||
|
switch (quotaType) {
|
||||||
|
case 'MAX_ADDITIONAL_USERS':
|
||||||
|
return <Users className="h-5 w-5" />;
|
||||||
|
case 'MAX_RESOURCES':
|
||||||
|
return <Briefcase className="h-5 w-5" />;
|
||||||
|
case 'MAX_SERVICES':
|
||||||
|
return <Calendar className="h-5 w-5" />;
|
||||||
|
default:
|
||||||
|
return <AlertTriangle className="h-5 w-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOwner && !isManager) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
Only business owners and managers can access quota settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={loadQuotaStatus}
|
||||||
|
className="mt-4 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
{t('common.reload', 'Reload')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOverages = quotaStatus && quotaStatus.active_overages.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<AlertTriangle className="text-amber-500" />
|
||||||
|
{t('quota.page.title', 'Quota Management')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('quota.page.subtitle', 'Manage your account limits and usage')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
||||||
|
<Check className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-green-800">{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No overages */}
|
||||||
|
{!hasOverages && (
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
|
||||||
|
<Check className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-green-800 dark:text-green-200">
|
||||||
|
{t('quota.page.noOverages', 'You are within your plan limits.')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-green-600 dark:text-green-400 mt-2">
|
||||||
|
All your resources are within the limits of your current plan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Usage Overview */}
|
||||||
|
{quotaStatus && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||||
|
Current Usage
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{Object.entries(quotaStatus.usage).map(([quotaType, usage]) => {
|
||||||
|
const isOver = usage.limit > 0 && usage.current > usage.limit;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={quotaType}
|
||||||
|
className={`p-4 rounded-lg border ${
|
||||||
|
isOver
|
||||||
|
? 'border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-900/20'
|
||||||
|
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-700/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
{getQuotaIcon(quotaType)}
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{usage.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
<span className={isOver ? 'text-red-600' : 'text-gray-900 dark:text-white'}>
|
||||||
|
{usage.current}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-lg">
|
||||||
|
{' / '}
|
||||||
|
{usage.limit < 0 ? 'Unlimited' : usage.limit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOver && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
Over by {usage.current - usage.limit}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Overages */}
|
||||||
|
{hasOverages && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
Active Overages
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{quotaStatus!.active_overages.map((overage) => (
|
||||||
|
<div
|
||||||
|
key={overage.id}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg border border-amber-300 dark:border-amber-700 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Overage Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleOverage(overage)}
|
||||||
|
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-2 rounded-lg ${
|
||||||
|
overage.days_remaining <= 1
|
||||||
|
? 'bg-red-100 text-red-600'
|
||||||
|
: overage.days_remaining <= 7
|
||||||
|
? 'bg-amber-100 text-amber-600'
|
||||||
|
: 'bg-amber-50 text-amber-500'
|
||||||
|
}`}>
|
||||||
|
{getQuotaIcon(overage.quota_type)}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{overage.display_name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{overage.current_usage} / {overage.allowed_limit}
|
||||||
|
{' • '}
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
{overage.overage_amount} over limit
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
overage.days_remaining <= 1
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: overage.days_remaining <= 7
|
||||||
|
? 'bg-amber-100 text-amber-700'
|
||||||
|
: 'bg-amber-50 text-amber-600'
|
||||||
|
}`}>
|
||||||
|
<Clock className="h-4 w-4 inline mr-1" />
|
||||||
|
{overage.days_remaining} days left
|
||||||
|
</div>
|
||||||
|
{expandedOverage === overage.id ? (
|
||||||
|
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded Content */}
|
||||||
|
{expandedOverage === overage.id && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<AlertTriangle className="h-4 w-4 inline mr-1" />
|
||||||
|
{t('quota.page.autoArchiveWarning',
|
||||||
|
'After the grace period ({{date}}), the oldest {{count}} {{type}} will be automatically archived.',
|
||||||
|
{
|
||||||
|
date: formatDate(overage.grace_period_ends_at),
|
||||||
|
count: overage.overage_amount,
|
||||||
|
type: overage.display_name
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||||
|
{t('quota.page.selectToArchive', 'Select items to archive')}
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{/* Resource List */}
|
||||||
|
{resources[overage.quota_type] ? (
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{resources[overage.quota_type]
|
||||||
|
.filter(r => !r.is_archived)
|
||||||
|
.map((resource) => (
|
||||||
|
<label
|
||||||
|
key={resource.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedResources[overage.quota_type]?.has(resource.id) || false}
|
||||||
|
onChange={() => toggleResourceSelection(overage.quota_type, resource.id)}
|
||||||
|
className="h-4 w-4 text-brand-600 rounded border-gray-300 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{resource.name}
|
||||||
|
</p>
|
||||||
|
{resource.email && (
|
||||||
|
<p className="text-sm text-gray-500">{resource.email}</p>
|
||||||
|
)}
|
||||||
|
{resource.role && (
|
||||||
|
<p className="text-sm text-gray-500">{resource.role}</p>
|
||||||
|
)}
|
||||||
|
{resource.type && (
|
||||||
|
<p className="text-sm text-gray-500">{resource.type}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{resource.created_at && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Created {formatDate(resource.created_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Already Archived */}
|
||||||
|
{resources[overage.quota_type]?.some(r => r.is_archived) && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h6 className="text-sm font-medium text-gray-500 mb-2">Already Archived</h6>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{resources[overage.quota_type]
|
||||||
|
.filter(r => r.is_archived)
|
||||||
|
.map((resource) => (
|
||||||
|
<div
|
||||||
|
key={resource.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50"
|
||||||
|
>
|
||||||
|
<Archive className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-gray-500">{resource.name}</span>
|
||||||
|
{resource.archived_at && (
|
||||||
|
<span className="text-xs text-gray-400 ml-auto">
|
||||||
|
Archived {formatDate(resource.archived_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-4 flex items-center gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => handleArchive(overage.quota_type)}
|
||||||
|
disabled={
|
||||||
|
archiving ||
|
||||||
|
!selectedResources[overage.quota_type] ||
|
||||||
|
selectedResources[overage.quota_type].size === 0
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{archiving ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t('quota.page.archiveSelected', 'Archive Selected')}
|
||||||
|
{selectedResources[overage.quota_type]?.size > 0 && (
|
||||||
|
<span className="bg-amber-700 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{selectedResources[overage.quota_type].size}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/settings/billing"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
{t('quota.page.upgradeInstead', 'Upgrade Plan Instead')}
|
||||||
|
</Link>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t('quota.page.exportData', 'Export Data')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Archive Warning */}
|
||||||
|
<p className="mt-3 text-sm text-gray-500">
|
||||||
|
<AlertTriangle className="h-4 w-4 inline mr-1" />
|
||||||
|
{t('quota.page.archiveWarning',
|
||||||
|
'Archived items will become read-only and cannot be used for new bookings.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuotaSettings;
|
||||||
@@ -22,3 +22,6 @@ export { default as CommunicationSettings } from './CommunicationSettings';
|
|||||||
|
|
||||||
// Billing
|
// Billing
|
||||||
export { default as BillingSettings } from './BillingSettings';
|
export { default as BillingSettings } from './BillingSettings';
|
||||||
|
|
||||||
|
// Quota Management
|
||||||
|
export { default as QuotaSettings } from './QuotaSettings';
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ export interface NotificationPreferences {
|
|||||||
marketing: boolean;
|
marketing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuotaOverage {
|
||||||
|
id: number;
|
||||||
|
quota_type: string;
|
||||||
|
display_name: string;
|
||||||
|
current_usage: number;
|
||||||
|
allowed_limit: number;
|
||||||
|
overage_amount: number;
|
||||||
|
days_remaining: number;
|
||||||
|
grace_period_ends_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -109,6 +120,7 @@ export interface User {
|
|||||||
can_invite_staff?: boolean;
|
can_invite_staff?: boolean;
|
||||||
can_access_tickets?: boolean;
|
can_access_tickets?: boolean;
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
|
quota_overages?: QuotaOverage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||||
|
|||||||
@@ -279,6 +279,28 @@ CELERY_TASK_TIME_LIMIT = 5 * 60
|
|||||||
CELERY_TASK_SOFT_TIME_LIMIT = 60
|
CELERY_TASK_SOFT_TIME_LIMIT = 60
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler
|
||||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||||
|
|
||||||
|
# Celery Beat Schedule (for reference - actual schedule managed in database)
|
||||||
|
# These tasks are created via data migration in core app
|
||||||
|
# CELERY_BEAT_SCHEDULE = {
|
||||||
|
# 'quota-check-all-tenants': {
|
||||||
|
# 'task': 'core.tasks.check_all_tenant_quotas',
|
||||||
|
# 'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
|
||||||
|
# },
|
||||||
|
# 'quota-send-reminders': {
|
||||||
|
# 'task': 'core.tasks.send_quota_reminder_emails',
|
||||||
|
# 'schedule': crontab(hour=8, minute=0), # Daily at 8 AM
|
||||||
|
# },
|
||||||
|
# 'quota-process-expired': {
|
||||||
|
# 'task': 'core.tasks.process_expired_quotas',
|
||||||
|
# 'schedule': crontab(hour=3, minute=0), # Daily at 3 AM
|
||||||
|
# },
|
||||||
|
# 'quota-cleanup-old': {
|
||||||
|
# 'task': 'core.tasks.cleanup_old_resolved_overages',
|
||||||
|
# 'schedule': crontab(day_of_week=0, hour=4, minute=0), # Weekly on Sunday 4 AM
|
||||||
|
# },
|
||||||
|
# }
|
||||||
|
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events
|
||||||
CELERY_WORKER_SEND_TASK_EVENTS = True
|
CELERY_WORKER_SEND_TASK_EVENTS = True
|
||||||
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event
|
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ from core.email_autoconfig import (
|
|||||||
AppleConfigProfileView,
|
AppleConfigProfileView,
|
||||||
WellKnownAutoconfigView,
|
WellKnownAutoconfigView,
|
||||||
)
|
)
|
||||||
|
from core.api_views import (
|
||||||
|
quota_status_view,
|
||||||
|
quota_resources_view,
|
||||||
|
quota_archive_view,
|
||||||
|
quota_unarchive_view,
|
||||||
|
quota_overage_detail_view,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
@@ -115,6 +122,12 @@ urlpatterns += [
|
|||||||
path("sandbox/status/", sandbox_status_view, name="sandbox_status"),
|
path("sandbox/status/", sandbox_status_view, name="sandbox_status"),
|
||||||
path("sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"),
|
path("sandbox/toggle/", sandbox_toggle_view, name="sandbox_toggle"),
|
||||||
path("sandbox/reset/", sandbox_reset_view, name="sandbox_reset"),
|
path("sandbox/reset/", sandbox_reset_view, name="sandbox_reset"),
|
||||||
|
# Quota Management API
|
||||||
|
path("quota/status/", quota_status_view, name="quota_status"),
|
||||||
|
path("quota/resources/<str:quota_type>/", quota_resources_view, name="quota_resources"),
|
||||||
|
path("quota/archive/", quota_archive_view, name="quota_archive"),
|
||||||
|
path("quota/unarchive/", quota_unarchive_view, name="quota_unarchive"),
|
||||||
|
path("quota/overages/<int:overage_id>/", quota_overage_detail_view, name="quota_overage_detail"),
|
||||||
# MFA (Two-Factor Authentication) API
|
# MFA (Two-Factor Authentication) API
|
||||||
path("auth/mfa/status/", mfa_status, name="mfa_status"),
|
path("auth/mfa/status/", mfa_status, name="mfa_status"),
|
||||||
path("auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"),
|
path("auth/mfa/phone/send/", send_phone_verification, name="mfa_phone_send"),
|
||||||
|
|||||||
317
smoothschedule/core/api_views.py
Normal file
317
smoothschedule/core/api_views.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
API views for quota management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from .quota_service import QuotaService
|
||||||
|
from .models import QuotaOverage
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def is_owner_or_manager(user):
|
||||||
|
"""Check if user is a tenant owner or manager."""
|
||||||
|
return user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def quota_status_view(request):
|
||||||
|
"""
|
||||||
|
Get current quota status for the user's tenant.
|
||||||
|
|
||||||
|
GET /api/quota/status/
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- active_overages: List of active quota overages
|
||||||
|
- usage: Current usage for each quota type
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No tenant associated with this user'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner_or_manager(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only owners and managers can view quota status'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
service = QuotaService(user.tenant)
|
||||||
|
|
||||||
|
# Get active overages
|
||||||
|
overages = service.get_active_overages()
|
||||||
|
|
||||||
|
# Get current usage for all quota types
|
||||||
|
usage = {}
|
||||||
|
for quota_type, config in service.QUOTA_CONFIG.items():
|
||||||
|
usage[quota_type] = {
|
||||||
|
'current': service.get_current_usage(quota_type),
|
||||||
|
'limit': service.get_limit(quota_type),
|
||||||
|
'display_name': config['display_name'],
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'active_overages': overages,
|
||||||
|
'usage': usage,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def quota_resources_view(request, quota_type):
|
||||||
|
"""
|
||||||
|
Get list of resources that can be archived for a specific quota type.
|
||||||
|
|
||||||
|
GET /api/quota/resources/<quota_type>/
|
||||||
|
|
||||||
|
Returns list of resources with their details and whether they're archived.
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No tenant associated with this user'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner_or_manager(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only owners and managers can view quota resources'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
|
||||||
|
if quota_type == 'MAX_ADDITIONAL_USERS':
|
||||||
|
# Get users excluding owner
|
||||||
|
users = User.objects.filter(
|
||||||
|
tenant=user.tenant
|
||||||
|
).exclude(
|
||||||
|
role=User.Role.TENANT_OWNER
|
||||||
|
).order_by('date_joined')
|
||||||
|
|
||||||
|
for u in users:
|
||||||
|
resources.append({
|
||||||
|
'id': u.id,
|
||||||
|
'name': u.get_full_name() or u.username,
|
||||||
|
'email': u.email,
|
||||||
|
'role': u.get_role_display(),
|
||||||
|
'created_at': u.date_joined.isoformat(),
|
||||||
|
'is_archived': u.is_archived_by_quota,
|
||||||
|
'archived_at': u.archived_by_quota_at.isoformat() if u.archived_by_quota_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_RESOURCES':
|
||||||
|
from schedule.models import Resource
|
||||||
|
for r in Resource.objects.all().order_by('created_at'):
|
||||||
|
resources.append({
|
||||||
|
'id': r.id,
|
||||||
|
'name': r.name,
|
||||||
|
'type': r.get_type_display() if hasattr(r, 'get_type_display') else r.type,
|
||||||
|
'created_at': r.created_at.isoformat() if hasattr(r, 'created_at') else None,
|
||||||
|
'is_archived': r.is_archived_by_quota,
|
||||||
|
'archived_at': r.archived_by_quota_at.isoformat() if r.archived_by_quota_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_SERVICES':
|
||||||
|
from schedule.models import Service
|
||||||
|
for s in Service.objects.all().order_by('created_at'):
|
||||||
|
resources.append({
|
||||||
|
'id': s.id,
|
||||||
|
'name': s.name,
|
||||||
|
'duration': s.duration,
|
||||||
|
'price': str(s.price) if s.price else None,
|
||||||
|
'created_at': s.created_at.isoformat() if hasattr(s, 'created_at') else None,
|
||||||
|
'is_archived': s.is_archived_by_quota,
|
||||||
|
'archived_at': s.archived_by_quota_at.isoformat() if s.archived_by_quota_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Unknown quota type: {quota_type}'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'quota_type': quota_type,
|
||||||
|
'resources': resources,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def quota_archive_view(request):
|
||||||
|
"""
|
||||||
|
Archive selected resources to resolve quota overage.
|
||||||
|
|
||||||
|
POST /api/quota/archive/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
- quota_type: The quota type (e.g., 'MAX_ADDITIONAL_USERS')
|
||||||
|
- resource_ids: List of resource IDs to archive
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No tenant associated with this user'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner_or_manager(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only owners and managers can archive resources'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
quota_type = request.data.get('quota_type')
|
||||||
|
resource_ids = request.data.get('resource_ids', [])
|
||||||
|
|
||||||
|
if not quota_type:
|
||||||
|
return Response(
|
||||||
|
{'error': 'quota_type is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resource_ids:
|
||||||
|
return Response(
|
||||||
|
{'error': 'resource_ids is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
service = QuotaService(user.tenant)
|
||||||
|
|
||||||
|
# Verify quota type is valid
|
||||||
|
if quota_type not in service.QUOTA_CONFIG:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Unknown quota type: {quota_type}'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Archive the resources
|
||||||
|
archived_count = service.archive_resources(quota_type, resource_ids)
|
||||||
|
|
||||||
|
# Check if overage is now resolved
|
||||||
|
current_usage = service.get_current_usage(quota_type)
|
||||||
|
limit = service.get_limit(quota_type)
|
||||||
|
is_resolved = current_usage <= limit
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'archived_count': archived_count,
|
||||||
|
'current_usage': current_usage,
|
||||||
|
'limit': limit,
|
||||||
|
'is_resolved': is_resolved,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def quota_unarchive_view(request):
|
||||||
|
"""
|
||||||
|
Unarchive a resource (only if there's room in the quota).
|
||||||
|
|
||||||
|
POST /api/quota/unarchive/
|
||||||
|
|
||||||
|
Body:
|
||||||
|
- quota_type: The quota type
|
||||||
|
- resource_id: The resource ID to unarchive
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No tenant associated with this user'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner_or_manager(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only owners and managers can unarchive resources'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
quota_type = request.data.get('quota_type')
|
||||||
|
resource_id = request.data.get('resource_id')
|
||||||
|
|
||||||
|
if not quota_type or not resource_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'quota_type and resource_id are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
service = QuotaService(user.tenant)
|
||||||
|
|
||||||
|
# Try to unarchive
|
||||||
|
success = service.unarchive_resource(quota_type, resource_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Cannot unarchive: quota limit would be exceeded'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'resource_id': resource_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def quota_overage_detail_view(request, overage_id):
|
||||||
|
"""
|
||||||
|
Get details for a specific quota overage.
|
||||||
|
|
||||||
|
GET /api/quota/overages/<overage_id>/
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No tenant associated with this user'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not is_owner_or_manager(user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only owners and managers can view overage details'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
overage = QuotaOverage.objects.get(
|
||||||
|
id=overage_id,
|
||||||
|
tenant=user.tenant
|
||||||
|
)
|
||||||
|
except QuotaOverage.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Overage not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
service = QuotaService(user.tenant)
|
||||||
|
config = service.QUOTA_CONFIG.get(overage.quota_type, {})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'id': overage.id,
|
||||||
|
'quota_type': overage.quota_type,
|
||||||
|
'display_name': config.get('display_name', overage.quota_type),
|
||||||
|
'status': overage.status,
|
||||||
|
'current_usage': overage.current_usage,
|
||||||
|
'allowed_limit': overage.allowed_limit,
|
||||||
|
'overage_amount': overage.overage_amount,
|
||||||
|
'days_remaining': overage.days_remaining,
|
||||||
|
'grace_period_ends_at': overage.grace_period_ends_at.isoformat() if overage.grace_period_ends_at else None,
|
||||||
|
'created_at': overage.created_at.isoformat(),
|
||||||
|
'initial_email_sent_at': overage.initial_email_sent_at.isoformat() if overage.initial_email_sent_at else None,
|
||||||
|
'week_reminder_sent_at': overage.week_reminder_sent_at.isoformat() if overage.week_reminder_sent_at else None,
|
||||||
|
'day_reminder_sent_at': overage.day_reminder_sent_at.isoformat() if overage.day_reminder_sent_at else None,
|
||||||
|
'archived_resource_ids': overage.archived_resource_ids,
|
||||||
|
})
|
||||||
102
smoothschedule/core/management/commands/setup_quota_tasks.py
Normal file
102
smoothschedule/core/management/commands/setup_quota_tasks.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""
|
||||||
|
Management command to set up periodic Celery tasks for quota management.
|
||||||
|
|
||||||
|
Run this after deployment:
|
||||||
|
python manage.py setup_quota_tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Set up periodic Celery Beat tasks for quota management'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from django_celery_beat.models import PeriodicTask, CrontabSchedule
|
||||||
|
|
||||||
|
self.stdout.write('Setting up quota management periodic tasks...')
|
||||||
|
|
||||||
|
# Create crontab schedules
|
||||||
|
# Daily at 2 AM - check all tenants for quota overages
|
||||||
|
schedule_2am, _ = CrontabSchedule.objects.get_or_create(
|
||||||
|
minute='0',
|
||||||
|
hour='2',
|
||||||
|
day_of_week='*',
|
||||||
|
day_of_month='*',
|
||||||
|
month_of_year='*',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Daily at 3 AM - process expired grace periods
|
||||||
|
schedule_3am, _ = CrontabSchedule.objects.get_or_create(
|
||||||
|
minute='0',
|
||||||
|
hour='3',
|
||||||
|
day_of_week='*',
|
||||||
|
day_of_month='*',
|
||||||
|
month_of_year='*',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Daily at 8 AM - send reminder emails
|
||||||
|
schedule_8am, _ = CrontabSchedule.objects.get_or_create(
|
||||||
|
minute='0',
|
||||||
|
hour='8',
|
||||||
|
day_of_week='*',
|
||||||
|
day_of_month='*',
|
||||||
|
month_of_year='*',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Weekly on Sunday at 4 AM - cleanup old overage records
|
||||||
|
schedule_sunday_4am, _ = CrontabSchedule.objects.get_or_create(
|
||||||
|
minute='0',
|
||||||
|
hour='4',
|
||||||
|
day_of_week='0', # Sunday
|
||||||
|
day_of_month='*',
|
||||||
|
month_of_year='*',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create periodic tasks
|
||||||
|
tasks = [
|
||||||
|
{
|
||||||
|
'name': 'quota-check-all-tenants',
|
||||||
|
'task': 'core.tasks.check_all_tenant_quotas',
|
||||||
|
'crontab': schedule_2am,
|
||||||
|
'description': 'Check all tenants for quota overages (runs daily at 2 AM)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'quota-process-expired',
|
||||||
|
'task': 'core.tasks.process_expired_quotas',
|
||||||
|
'crontab': schedule_3am,
|
||||||
|
'description': 'Auto-archive resources for expired grace periods (runs daily at 3 AM)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'quota-send-reminders',
|
||||||
|
'task': 'core.tasks.send_quota_reminder_emails',
|
||||||
|
'crontab': schedule_8am,
|
||||||
|
'description': 'Send quota overage reminder emails (runs daily at 8 AM)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'quota-cleanup-old',
|
||||||
|
'task': 'core.tasks.cleanup_old_resolved_overages',
|
||||||
|
'crontab': schedule_sunday_4am,
|
||||||
|
'description': 'Clean up old resolved quota overage records (runs weekly on Sunday at 4 AM)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for task_config in tasks:
|
||||||
|
task, created = PeriodicTask.objects.update_or_create(
|
||||||
|
name=task_config['name'],
|
||||||
|
defaults={
|
||||||
|
'task': task_config['task'],
|
||||||
|
'crontab': task_config['crontab'],
|
||||||
|
'description': task_config['description'],
|
||||||
|
'enabled': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
status = 'Created' if created else 'Updated'
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" {status}: {task.name}"))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('\nQuota management tasks set up successfully!'))
|
||||||
|
self.stdout.write('\nTasks configured:')
|
||||||
|
self.stdout.write(' - quota-check-all-tenants: Daily at 2 AM')
|
||||||
|
self.stdout.write(' - quota-process-expired: Daily at 3 AM')
|
||||||
|
self.stdout.write(' - quota-send-reminders: Daily at 8 AM')
|
||||||
|
self.stdout.write(' - quota-cleanup-old: Weekly on Sunday at 4 AM')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-02 16:57
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0016_tenant_can_use_calendar_sync'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tierlimit',
|
||||||
|
name='feature_code',
|
||||||
|
field=models.CharField(help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='QuotaOverage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quota_type', models.CharField(choices=[('MAX_ADDITIONAL_USERS', 'Additional Users'), ('MAX_RESOURCES', 'Resources'), ('MAX_SERVICES', 'Services'), ('MAX_EMAIL_TEMPLATES', 'Email Templates'), ('MAX_AUTOMATED_TASKS', 'Automated Tasks')], help_text='Which quota limit was exceeded', max_length=50)),
|
||||||
|
('status', models.CharField(choices=[('ACTIVE', 'Active - Grace period in effect'), ('RESOLVED', 'Resolved - User reduced usage or upgraded'), ('ARCHIVED', 'Archived - Grace period expired, resources archived'), ('CANCELLED', 'Cancelled - Admin intervention')], default='ACTIVE', max_length=20)),
|
||||||
|
('current_usage', models.IntegerField(help_text='Usage count when overage was detected')),
|
||||||
|
('allowed_limit', models.IntegerField(help_text='New limit after plan change')),
|
||||||
|
('overage_amount', models.IntegerField(help_text='Number of items over the limit (usage - limit)')),
|
||||||
|
('grace_period_days', models.IntegerField(default=30, help_text='Number of days before auto-archive')),
|
||||||
|
('detected_at', models.DateTimeField(auto_now_add=True, help_text='When the overage was first detected')),
|
||||||
|
('grace_period_ends_at', models.DateTimeField(help_text='When the grace period expires')),
|
||||||
|
('initial_email_sent_at', models.DateTimeField(blank=True, help_text='When the initial overage notification was sent', null=True)),
|
||||||
|
('week_reminder_sent_at', models.DateTimeField(blank=True, help_text='When the 7-day warning was sent', null=True)),
|
||||||
|
('day_reminder_sent_at', models.DateTimeField(blank=True, help_text='When the 1-day warning was sent', null=True)),
|
||||||
|
('resolved_at', models.DateTimeField(blank=True, help_text='When the overage was resolved', null=True)),
|
||||||
|
('resolution_method', models.CharField(blank=True, choices=[('USER_ARCHIVED', 'User selected resources to archive'), ('USER_DELETED', 'User deleted excess resources'), ('USER_UPGRADED', 'User upgraded their plan'), ('AUTO_ARCHIVED', 'Auto-archived after grace period'), ('ADMIN_RESOLVED', 'Resolved by admin')], max_length=50, null=True)),
|
||||||
|
('archived_resource_ids', models.JSONField(blank=True, default=list, help_text='IDs of resources that were archived due to this overage')),
|
||||||
|
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quota_overages', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-detected_at'],
|
||||||
|
'indexes': [models.Index(fields=['tenant', 'status'], name='core_quotao_tenant__5f1a84_idx'), models.Index(fields=['grace_period_ends_at', 'status'], name='core_quotao_grace_p_8a39bd_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -774,21 +774,176 @@ class TierLimit(models.Model):
|
|||||||
('ENTERPRISE', 'Enterprise'),
|
('ENTERPRISE', 'Enterprise'),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
feature_code = models.CharField(
|
feature_code = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')"
|
help_text="Feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')"
|
||||||
)
|
)
|
||||||
|
|
||||||
limit = models.IntegerField(
|
limit = models.IntegerField(
|
||||||
default=0,
|
default=0,
|
||||||
help_text="Maximum allowed count for this feature"
|
help_text="Maximum allowed count for this feature"
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['tier', 'feature_code']
|
unique_together = ['tier', 'feature_code']
|
||||||
ordering = ['tier', 'feature_code']
|
ordering = ['tier', 'feature_code']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
return f"{self.tier} - {self.feature_code}: {self.limit}"
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaOverage(models.Model):
|
||||||
|
"""
|
||||||
|
Tracks quota overages when a tenant exceeds their plan limits.
|
||||||
|
|
||||||
|
Created when:
|
||||||
|
- Tenant downgrades their plan
|
||||||
|
- Tenant's plan expires/lapses to free tier
|
||||||
|
- Plan limits are reduced administratively
|
||||||
|
|
||||||
|
Grace period: 30 days to resolve the overage by:
|
||||||
|
1. Selecting which resources to archive
|
||||||
|
2. Upgrading their plan
|
||||||
|
3. Deleting excess resources
|
||||||
|
|
||||||
|
After grace period expires, excess resources are automatically archived.
|
||||||
|
"""
|
||||||
|
|
||||||
|
QUOTA_TYPES = [
|
||||||
|
('MAX_ADDITIONAL_USERS', 'Additional Users'),
|
||||||
|
('MAX_RESOURCES', 'Resources'),
|
||||||
|
('MAX_SERVICES', 'Services'),
|
||||||
|
('MAX_EMAIL_TEMPLATES', 'Email Templates'),
|
||||||
|
('MAX_AUTOMATED_TASKS', 'Automated Tasks'),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('ACTIVE', 'Active - Grace period in effect'),
|
||||||
|
('RESOLVED', 'Resolved - User reduced usage or upgraded'),
|
||||||
|
('ARCHIVED', 'Archived - Grace period expired, resources archived'),
|
||||||
|
('CANCELLED', 'Cancelled - Admin intervention'),
|
||||||
|
]
|
||||||
|
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
Tenant,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='quota_overages'
|
||||||
|
)
|
||||||
|
|
||||||
|
quota_type = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=QUOTA_TYPES,
|
||||||
|
help_text="Which quota limit was exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='ACTIVE'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Snapshot at time of overage
|
||||||
|
current_usage = models.IntegerField(
|
||||||
|
help_text="Usage count when overage was detected"
|
||||||
|
)
|
||||||
|
allowed_limit = models.IntegerField(
|
||||||
|
help_text="New limit after plan change"
|
||||||
|
)
|
||||||
|
overage_amount = models.IntegerField(
|
||||||
|
help_text="Number of items over the limit (usage - limit)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Grace period tracking
|
||||||
|
grace_period_days = models.IntegerField(
|
||||||
|
default=30,
|
||||||
|
help_text="Number of days before auto-archive"
|
||||||
|
)
|
||||||
|
detected_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
help_text="When the overage was first detected"
|
||||||
|
)
|
||||||
|
grace_period_ends_at = models.DateTimeField(
|
||||||
|
help_text="When the grace period expires"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notification tracking
|
||||||
|
initial_email_sent_at = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="When the initial overage notification was sent"
|
||||||
|
)
|
||||||
|
week_reminder_sent_at = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="When the 7-day warning was sent"
|
||||||
|
)
|
||||||
|
day_reminder_sent_at = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="When the 1-day warning was sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolution tracking
|
||||||
|
resolved_at = models.DateTimeField(
|
||||||
|
null=True, blank=True,
|
||||||
|
help_text="When the overage was resolved"
|
||||||
|
)
|
||||||
|
resolution_method = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
null=True, blank=True,
|
||||||
|
choices=[
|
||||||
|
('USER_ARCHIVED', 'User selected resources to archive'),
|
||||||
|
('USER_DELETED', 'User deleted excess resources'),
|
||||||
|
('USER_UPGRADED', 'User upgraded their plan'),
|
||||||
|
('AUTO_ARCHIVED', 'Auto-archived after grace period'),
|
||||||
|
('ADMIN_RESOLVED', 'Resolved by admin'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON field to track which resources were archived
|
||||||
|
archived_resource_ids = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text="IDs of resources that were archived due to this overage"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-detected_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'status']),
|
||||||
|
models.Index(fields=['grace_period_ends_at', 'status']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.tenant.name} - {self.get_quota_type_display()} overage ({self.overage_amount} over)"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Auto-calculate grace period end date on creation
|
||||||
|
if not self.pk and not self.grace_period_ends_at:
|
||||||
|
self.grace_period_ends_at = timezone.now() + timedelta(days=self.grace_period_days)
|
||||||
|
|
||||||
|
# Auto-calculate overage amount
|
||||||
|
self.overage_amount = max(0, self.current_usage - self.allowed_limit)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_remaining(self):
|
||||||
|
"""Returns the number of days remaining in the grace period."""
|
||||||
|
if self.status != 'ACTIVE':
|
||||||
|
return 0
|
||||||
|
remaining = (self.grace_period_ends_at - timezone.now()).days
|
||||||
|
return max(0, remaining)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_grace_period_expired(self):
|
||||||
|
"""Check if the grace period has expired."""
|
||||||
|
return timezone.now() >= self.grace_period_ends_at
|
||||||
|
|
||||||
|
def resolve(self, method, archived_ids=None):
|
||||||
|
"""Mark this overage as resolved."""
|
||||||
|
self.status = 'RESOLVED' if method != 'AUTO_ARCHIVED' else 'ARCHIVED'
|
||||||
|
self.resolved_at = timezone.now()
|
||||||
|
self.resolution_method = method
|
||||||
|
if archived_ids:
|
||||||
|
self.archived_resource_ids = archived_ids
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ def HasQuota(feature_code):
|
|||||||
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
permission_classes = [IsAuthenticated, HasQuota('MAX_RESOURCES')]
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_USERS')
|
feature_code: TierLimit feature code (e.g., 'MAX_RESOURCES', 'MAX_ADDITIONAL_USERS')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
QuotaPermission class configured for the feature
|
QuotaPermission class configured for the feature
|
||||||
@@ -218,9 +218,10 @@ def HasQuota(feature_code):
|
|||||||
|
|
||||||
# Map feature codes to model paths for usage counting
|
# Map feature codes to model paths for usage counting
|
||||||
# CRITICAL: This map must be populated for the permission to work
|
# CRITICAL: This map must be populated for the permission to work
|
||||||
|
# Note: MAX_ADDITIONAL_USERS requires special handling (shared schema + exclude owner)
|
||||||
USAGE_MAP = {
|
USAGE_MAP = {
|
||||||
'MAX_RESOURCES': 'schedule.Resource',
|
'MAX_RESOURCES': 'schedule.Resource',
|
||||||
'MAX_USERS': 'users.User',
|
'MAX_ADDITIONAL_USERS': 'users.User', # Renamed from MAX_USERS - excludes owner
|
||||||
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
|
||||||
'MAX_SERVICES': 'schedule.Service',
|
'MAX_SERVICES': 'schedule.Service',
|
||||||
'MAX_APPOINTMENTS': 'schedule.Event',
|
'MAX_APPOINTMENTS': 'schedule.Event',
|
||||||
@@ -271,10 +272,22 @@ def HasQuota(feature_code):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Count current usage
|
# Count current usage
|
||||||
# NOTE: django-tenants automatically scopes this query to tenant schema
|
# NOTE: Most models use django-tenants automatic scoping
|
||||||
|
# But User is in shared schema, so needs special handling
|
||||||
|
|
||||||
|
# Special handling for additional users (shared schema, exclude owner)
|
||||||
|
if feature_code == 'MAX_ADDITIONAL_USERS':
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
# Count users in this tenant, excluding the owner and archived users
|
||||||
|
current_count = User.objects.filter(
|
||||||
|
tenant=tenant,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).exclude(
|
||||||
|
role=User.Role.TENANT_OWNER
|
||||||
|
).count()
|
||||||
|
|
||||||
# Special handling for monthly appointment limit
|
# Special handling for monthly appointment limit
|
||||||
if feature_code == 'MAX_APPOINTMENTS':
|
elif feature_code == 'MAX_APPOINTMENTS':
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
# Count appointments in current month
|
# Count appointments in current month
|
||||||
@@ -286,8 +299,14 @@ def HasQuota(feature_code):
|
|||||||
start_time__gte=start_of_month,
|
start_time__gte=start_of_month,
|
||||||
start_time__lt=start_of_next_month
|
start_time__lt=start_of_next_month
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
# Standard counting for tenant-scoped models
|
||||||
else:
|
else:
|
||||||
current_count = Model.objects.count()
|
# Exclude archived resources from count
|
||||||
|
if hasattr(Model, 'is_archived_by_quota'):
|
||||||
|
current_count = Model.objects.filter(is_archived_by_quota=False).count()
|
||||||
|
else:
|
||||||
|
current_count = Model.objects.count()
|
||||||
|
|
||||||
# The "Hard Block": Enforce the limit
|
# The "Hard Block": Enforce the limit
|
||||||
if current_count >= limit:
|
if current_count >= limit:
|
||||||
|
|||||||
648
smoothschedule/core/quota_service.py
Normal file
648
smoothschedule/core/quota_service.py
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
"""
|
||||||
|
Quota Overage Service
|
||||||
|
|
||||||
|
Handles detection, tracking, and resolution of quota overages when tenants
|
||||||
|
exceed their plan limits (e.g., after downgrade or plan expiration).
|
||||||
|
|
||||||
|
Grace Period: 30 days
|
||||||
|
- Users can select which resources to archive
|
||||||
|
- After grace period, excess resources are auto-archived
|
||||||
|
- Archived resources become read-only (visible but not usable)
|
||||||
|
|
||||||
|
Email Notifications:
|
||||||
|
- Immediately when overage detected
|
||||||
|
- 7 days before grace period ends
|
||||||
|
- 1 day before grace period ends
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db import transaction
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import Tenant, QuotaOverage, TierLimit
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaService:
|
||||||
|
"""
|
||||||
|
Service class for managing quota overages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GRACE_PERIOD_DAYS = 30
|
||||||
|
|
||||||
|
# Quota types and their corresponding models/counting logic
|
||||||
|
QUOTA_CONFIG = {
|
||||||
|
'MAX_ADDITIONAL_USERS': {
|
||||||
|
'model': 'smoothschedule.users.models.User',
|
||||||
|
'display_name': 'additional team members',
|
||||||
|
'count_method': 'count_additional_users',
|
||||||
|
},
|
||||||
|
'MAX_RESOURCES': {
|
||||||
|
'model': 'schedule.models.Resource',
|
||||||
|
'display_name': 'resources',
|
||||||
|
'count_method': 'count_resources',
|
||||||
|
},
|
||||||
|
'MAX_SERVICES': {
|
||||||
|
'model': 'schedule.models.Service',
|
||||||
|
'display_name': 'services',
|
||||||
|
'count_method': 'count_services',
|
||||||
|
},
|
||||||
|
'MAX_EMAIL_TEMPLATES': {
|
||||||
|
'model': 'schedule.models.EmailTemplate',
|
||||||
|
'display_name': 'email templates',
|
||||||
|
'count_method': 'count_email_templates',
|
||||||
|
},
|
||||||
|
'MAX_AUTOMATED_TASKS': {
|
||||||
|
'model': 'schedule.models.ScheduledTask',
|
||||||
|
'display_name': 'automated tasks',
|
||||||
|
'count_method': 'count_automated_tasks',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, tenant: Tenant):
|
||||||
|
self.tenant = tenant
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Counting Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def count_additional_users(self) -> int:
|
||||||
|
"""Count additional users (excluding owner and archived)."""
|
||||||
|
return User.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).exclude(
|
||||||
|
role=User.Role.TENANT_OWNER
|
||||||
|
).count()
|
||||||
|
|
||||||
|
def count_resources(self) -> int:
|
||||||
|
"""Count active resources (excluding archived)."""
|
||||||
|
from schedule.models import Resource
|
||||||
|
return Resource.objects.filter(is_archived_by_quota=False).count()
|
||||||
|
|
||||||
|
def count_services(self) -> int:
|
||||||
|
"""Count active services (excluding archived)."""
|
||||||
|
from schedule.models import Service
|
||||||
|
return Service.objects.filter(is_archived_by_quota=False).count()
|
||||||
|
|
||||||
|
def count_email_templates(self) -> int:
|
||||||
|
"""Count email templates."""
|
||||||
|
from schedule.models import EmailTemplate
|
||||||
|
return EmailTemplate.objects.count()
|
||||||
|
|
||||||
|
def count_automated_tasks(self) -> int:
|
||||||
|
"""Count automated tasks."""
|
||||||
|
from schedule.models import ScheduledTask
|
||||||
|
return ScheduledTask.objects.count()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Limit Retrieval
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_current_usage(self, quota_type: str) -> int:
|
||||||
|
"""Get the current usage for a quota type."""
|
||||||
|
config = self.QUOTA_CONFIG.get(quota_type)
|
||||||
|
if not config:
|
||||||
|
return 0
|
||||||
|
count_method = getattr(self, config['count_method'])
|
||||||
|
return count_method()
|
||||||
|
|
||||||
|
def get_limit(self, quota_type: str) -> int:
|
||||||
|
"""Get the current limit for a quota type based on tenant's plan."""
|
||||||
|
# First check subscription plan if available
|
||||||
|
if self.tenant.subscription_plan:
|
||||||
|
limits = self.tenant.subscription_plan.limits or {}
|
||||||
|
# Convert quota type to plan limit key (e.g., MAX_ADDITIONAL_USERS -> max_additional_users)
|
||||||
|
limit_key = quota_type.lower()
|
||||||
|
if limit_key in limits:
|
||||||
|
return limits[limit_key]
|
||||||
|
|
||||||
|
# Fall back to TierLimit table
|
||||||
|
try:
|
||||||
|
tier_limit = TierLimit.objects.get(
|
||||||
|
tier=self.tenant.subscription_tier,
|
||||||
|
feature_code=quota_type
|
||||||
|
)
|
||||||
|
return tier_limit.limit
|
||||||
|
except TierLimit.DoesNotExist:
|
||||||
|
# No limit defined = unlimited
|
||||||
|
return -1 # -1 means unlimited
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Overage Detection
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def check_all_quotas(self) -> list[QuotaOverage]:
|
||||||
|
"""
|
||||||
|
Check all quota types for overages.
|
||||||
|
Returns list of newly created QuotaOverage records.
|
||||||
|
"""
|
||||||
|
new_overages = []
|
||||||
|
|
||||||
|
for quota_type, config in self.QUOTA_CONFIG.items():
|
||||||
|
overage = self.check_quota(quota_type)
|
||||||
|
if overage:
|
||||||
|
new_overages.append(overage)
|
||||||
|
|
||||||
|
return new_overages
|
||||||
|
|
||||||
|
def check_quota(self, quota_type: str) -> QuotaOverage | None:
|
||||||
|
"""
|
||||||
|
Check a specific quota type for overage.
|
||||||
|
Creates QuotaOverage record if over limit and none exists.
|
||||||
|
Returns the overage record or None.
|
||||||
|
"""
|
||||||
|
config = self.QUOTA_CONFIG.get(quota_type)
|
||||||
|
if not config:
|
||||||
|
logger.warning(f"Unknown quota type: {quota_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get current usage
|
||||||
|
count_method = getattr(self, config['count_method'])
|
||||||
|
current_usage = count_method()
|
||||||
|
|
||||||
|
# Get limit
|
||||||
|
limit = self.get_limit(quota_type)
|
||||||
|
|
||||||
|
# -1 means unlimited
|
||||||
|
if limit < 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if over limit
|
||||||
|
if current_usage <= limit:
|
||||||
|
# Not over limit - check if there's an active overage to resolve
|
||||||
|
self._resolve_overage_if_exists(quota_type)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Over limit - check for existing active overage
|
||||||
|
existing = QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
quota_type=quota_type,
|
||||||
|
status='ACTIVE'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update the existing overage with current counts
|
||||||
|
existing.current_usage = current_usage
|
||||||
|
existing.allowed_limit = limit
|
||||||
|
existing.save()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# Create new overage record
|
||||||
|
with transaction.atomic():
|
||||||
|
overage = QuotaOverage.objects.create(
|
||||||
|
tenant=self.tenant,
|
||||||
|
quota_type=quota_type,
|
||||||
|
current_usage=current_usage,
|
||||||
|
allowed_limit=limit,
|
||||||
|
overage_amount=current_usage - limit,
|
||||||
|
grace_period_days=self.GRACE_PERIOD_DAYS,
|
||||||
|
grace_period_ends_at=timezone.now() + timedelta(days=self.GRACE_PERIOD_DAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send initial notification email
|
||||||
|
self.send_overage_notification(overage, 'initial')
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created quota overage for {self.tenant.name}: "
|
||||||
|
f"{quota_type} ({current_usage}/{limit})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return overage
|
||||||
|
|
||||||
|
def _resolve_overage_if_exists(self, quota_type: str):
|
||||||
|
"""Resolve any existing active overage for this quota type."""
|
||||||
|
existing = QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
quota_type=quota_type,
|
||||||
|
status='ACTIVE'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.resolve('USER_DELETED')
|
||||||
|
logger.info(
|
||||||
|
f"Resolved quota overage for {self.tenant.name}: {quota_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Email Notifications
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def send_overage_notification(self, overage: QuotaOverage, notification_type: str):
|
||||||
|
"""
|
||||||
|
Send email notification about quota overage.
|
||||||
|
|
||||||
|
notification_type:
|
||||||
|
- 'initial': First notification when overage detected
|
||||||
|
- 'week_reminder': 7 days before grace period ends
|
||||||
|
- 'day_reminder': 1 day before grace period ends
|
||||||
|
"""
|
||||||
|
# Get tenant owner
|
||||||
|
owner = User.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
role=User.Role.TENANT_OWNER
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not owner or not owner.email:
|
||||||
|
logger.warning(
|
||||||
|
f"Cannot send overage notification for {self.tenant.name}: no owner email"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
config = self.QUOTA_CONFIG.get(overage.quota_type, {})
|
||||||
|
display_name = config.get('display_name', overage.quota_type)
|
||||||
|
|
||||||
|
# Prepare email context
|
||||||
|
context = {
|
||||||
|
'tenant': self.tenant,
|
||||||
|
'owner': owner,
|
||||||
|
'overage': overage,
|
||||||
|
'display_name': display_name,
|
||||||
|
'days_remaining': overage.days_remaining,
|
||||||
|
'grace_period_ends': overage.grace_period_ends_at,
|
||||||
|
'current_usage': overage.current_usage,
|
||||||
|
'allowed_limit': overage.allowed_limit,
|
||||||
|
'overage_amount': overage.overage_amount,
|
||||||
|
'manage_url': self._get_manage_url(),
|
||||||
|
'upgrade_url': self._get_upgrade_url(),
|
||||||
|
'export_url': self._get_export_url(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Select template based on notification type
|
||||||
|
if notification_type == 'initial':
|
||||||
|
subject = f"Action Required: Your {self.tenant.name} account has exceeded its quota"
|
||||||
|
template = 'emails/quota_overage_initial.html'
|
||||||
|
overage.initial_email_sent_at = timezone.now()
|
||||||
|
elif notification_type == 'week_reminder':
|
||||||
|
subject = f"Reminder: 7 days left to resolve quota overage for {self.tenant.name}"
|
||||||
|
template = 'emails/quota_overage_week_reminder.html'
|
||||||
|
overage.week_reminder_sent_at = timezone.now()
|
||||||
|
elif notification_type == 'day_reminder':
|
||||||
|
subject = f"Final Warning: 1 day left to resolve quota overage for {self.tenant.name}"
|
||||||
|
template = 'emails/quota_overage_day_reminder.html'
|
||||||
|
overage.day_reminder_sent_at = timezone.now()
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown notification type: {notification_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
overage.save()
|
||||||
|
|
||||||
|
# Render and send email
|
||||||
|
try:
|
||||||
|
html_message = render_to_string(template, context)
|
||||||
|
text_message = render_to_string(
|
||||||
|
template.replace('.html', '.txt'),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[owner.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Sent {notification_type} overage email to {owner.email} "
|
||||||
|
f"for {self.tenant.name}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to send overage email to {owner.email}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_manage_url(self) -> str:
|
||||||
|
"""Get URL for quota management page."""
|
||||||
|
domain = self.tenant.get_primary_domain()
|
||||||
|
if domain:
|
||||||
|
return f"https://{domain.domain}/settings/quota"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _get_upgrade_url(self) -> str:
|
||||||
|
"""Get URL for plan upgrade page."""
|
||||||
|
domain = self.tenant.get_primary_domain()
|
||||||
|
if domain:
|
||||||
|
return f"https://{domain.domain}/settings/subscription"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _get_export_url(self) -> str:
|
||||||
|
"""Get URL for data export page."""
|
||||||
|
domain = self.tenant.get_primary_domain()
|
||||||
|
if domain:
|
||||||
|
return f"https://{domain.domain}/settings/export"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Resource Archiving
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def archive_resources(self, quota_type: str, resource_ids: list[int]) -> int:
|
||||||
|
"""
|
||||||
|
Archive specific resources selected by the user.
|
||||||
|
Returns the number of resources archived.
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
if quota_type == 'MAX_ADDITIONAL_USERS':
|
||||||
|
count = User.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
id__in=resource_ids,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).exclude(
|
||||||
|
role=User.Role.TENANT_OWNER # Never archive owner
|
||||||
|
).update(
|
||||||
|
is_archived_by_quota=True,
|
||||||
|
archived_by_quota_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_RESOURCES':
|
||||||
|
from schedule.models import Resource
|
||||||
|
count = Resource.objects.filter(
|
||||||
|
id__in=resource_ids,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).update(
|
||||||
|
is_archived_by_quota=True,
|
||||||
|
archived_by_quota_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_SERVICES':
|
||||||
|
from schedule.models import Service
|
||||||
|
count = Service.objects.filter(
|
||||||
|
id__in=resource_ids,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).update(
|
||||||
|
is_archived_by_quota=True,
|
||||||
|
archived_by_quota_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update overage record
|
||||||
|
overage = QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
quota_type=quota_type,
|
||||||
|
status='ACTIVE'
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if overage:
|
||||||
|
# Check if resolved
|
||||||
|
count_method = getattr(self, self.QUOTA_CONFIG[quota_type]['count_method'])
|
||||||
|
current_usage = count_method()
|
||||||
|
|
||||||
|
if current_usage <= overage.allowed_limit:
|
||||||
|
overage.resolve('USER_ARCHIVED', resource_ids)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def unarchive_resource(self, quota_type: str, resource_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Unarchive a resource (swap with another that will be archived).
|
||||||
|
Returns True if successful.
|
||||||
|
"""
|
||||||
|
# Check if we have room to unarchive
|
||||||
|
count_method = getattr(self, self.QUOTA_CONFIG[quota_type]['count_method'])
|
||||||
|
current_usage = count_method()
|
||||||
|
limit = self.get_limit(quota_type)
|
||||||
|
|
||||||
|
if current_usage >= limit:
|
||||||
|
# No room - cannot unarchive without archiving another
|
||||||
|
return False
|
||||||
|
|
||||||
|
if quota_type == 'MAX_ADDITIONAL_USERS':
|
||||||
|
User.objects.filter(
|
||||||
|
id=resource_id,
|
||||||
|
tenant=self.tenant
|
||||||
|
).update(
|
||||||
|
is_archived_by_quota=False,
|
||||||
|
archived_by_quota_at=None
|
||||||
|
)
|
||||||
|
elif quota_type == 'MAX_RESOURCES':
|
||||||
|
from schedule.models import Resource
|
||||||
|
Resource.objects.filter(id=resource_id).update(
|
||||||
|
is_archived_by_quota=False,
|
||||||
|
archived_by_quota_at=None
|
||||||
|
)
|
||||||
|
elif quota_type == 'MAX_SERVICES':
|
||||||
|
from schedule.models import Service
|
||||||
|
Service.objects.filter(id=resource_id).update(
|
||||||
|
is_archived_by_quota=False,
|
||||||
|
archived_by_quota_at=None
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Auto-Archive (Grace Period Expired)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def auto_archive_expired(self) -> dict:
|
||||||
|
"""
|
||||||
|
Auto-archive resources for overages where grace period has expired.
|
||||||
|
Archives the oldest/least recently used resources.
|
||||||
|
Returns dict with counts of archived resources by type.
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
expired_overages = QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
status='ACTIVE',
|
||||||
|
grace_period_ends_at__lte=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
for overage in expired_overages:
|
||||||
|
archived_ids = self._auto_archive_for_overage(overage)
|
||||||
|
if archived_ids:
|
||||||
|
overage.resolve('AUTO_ARCHIVED', archived_ids)
|
||||||
|
results[overage.quota_type] = len(archived_ids)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _auto_archive_for_overage(self, overage: QuotaOverage) -> list[int]:
|
||||||
|
"""
|
||||||
|
Auto-archive excess resources for a specific overage.
|
||||||
|
Archives the oldest resources first.
|
||||||
|
Returns list of archived resource IDs.
|
||||||
|
"""
|
||||||
|
quota_type = overage.quota_type
|
||||||
|
excess_count = overage.overage_amount
|
||||||
|
archived_ids = []
|
||||||
|
|
||||||
|
if quota_type == 'MAX_ADDITIONAL_USERS':
|
||||||
|
# Archive oldest non-owner users
|
||||||
|
users_to_archive = User.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).exclude(
|
||||||
|
role=User.Role.TENANT_OWNER
|
||||||
|
).order_by('date_joined')[:excess_count]
|
||||||
|
|
||||||
|
for user in users_to_archive:
|
||||||
|
user.is_archived_by_quota = True
|
||||||
|
user.archived_by_quota_at = timezone.now()
|
||||||
|
user.save()
|
||||||
|
archived_ids.append(user.id)
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_RESOURCES':
|
||||||
|
from schedule.models import Resource
|
||||||
|
resources = Resource.objects.filter(
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).order_by('created_at')[:excess_count]
|
||||||
|
|
||||||
|
for resource in resources:
|
||||||
|
resource.is_archived_by_quota = True
|
||||||
|
resource.archived_by_quota_at = timezone.now()
|
||||||
|
resource.save()
|
||||||
|
archived_ids.append(resource.id)
|
||||||
|
|
||||||
|
elif quota_type == 'MAX_SERVICES':
|
||||||
|
from schedule.models import Service
|
||||||
|
services = Service.objects.filter(
|
||||||
|
is_archived_by_quota=False
|
||||||
|
).order_by('created_at')[:excess_count]
|
||||||
|
|
||||||
|
for service in services:
|
||||||
|
service.is_archived_by_quota = True
|
||||||
|
service.archived_by_quota_at = timezone.now()
|
||||||
|
service.save()
|
||||||
|
archived_ids.append(service.id)
|
||||||
|
|
||||||
|
return archived_ids
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Status Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def get_active_overages(self) -> list[dict]:
|
||||||
|
"""Get all active quota overages for this tenant."""
|
||||||
|
overages = QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
status='ACTIVE'
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': o.id,
|
||||||
|
'quota_type': o.quota_type,
|
||||||
|
'display_name': self.QUOTA_CONFIG.get(o.quota_type, {}).get(
|
||||||
|
'display_name', o.quota_type
|
||||||
|
),
|
||||||
|
'current_usage': o.current_usage,
|
||||||
|
'allowed_limit': o.allowed_limit,
|
||||||
|
'overage_amount': o.overage_amount,
|
||||||
|
'days_remaining': o.days_remaining,
|
||||||
|
'grace_period_ends_at': o.grace_period_ends_at.isoformat() if o.grace_period_ends_at else None,
|
||||||
|
}
|
||||||
|
for o in overages
|
||||||
|
]
|
||||||
|
|
||||||
|
def has_active_overages(self) -> bool:
|
||||||
|
"""Check if tenant has any active quota overages."""
|
||||||
|
return QuotaOverage.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
status='ACTIVE'
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def check_tenant_quotas(tenant: Tenant) -> list[QuotaOverage]:
|
||||||
|
"""
|
||||||
|
Check all quotas for a tenant and create overage records if needed.
|
||||||
|
Call this after plan downgrades or billing failures.
|
||||||
|
"""
|
||||||
|
service = QuotaService(tenant)
|
||||||
|
return service.check_all_quotas()
|
||||||
|
|
||||||
|
|
||||||
|
def process_expired_grace_periods() -> dict:
|
||||||
|
"""
|
||||||
|
Process all tenants with expired grace periods.
|
||||||
|
Call this from a daily Celery task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with counts of processed overages and archived resources
|
||||||
|
"""
|
||||||
|
results = {
|
||||||
|
'overages_processed': 0,
|
||||||
|
'total_archived': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find all tenants with expired overages
|
||||||
|
expired_overages = QuotaOverage.objects.filter(
|
||||||
|
status='ACTIVE',
|
||||||
|
grace_period_ends_at__lte=timezone.now()
|
||||||
|
).values_list('tenant_id', flat=True).distinct()
|
||||||
|
|
||||||
|
for tenant_id in expired_overages:
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
service = QuotaService(tenant)
|
||||||
|
archive_results = service.auto_archive_expired()
|
||||||
|
if archive_results:
|
||||||
|
logger.info(f"Auto-archived for {tenant.name}: {archive_results}")
|
||||||
|
results['overages_processed'] += len(archive_results)
|
||||||
|
results['total_archived'] += sum(archive_results.values())
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing expired overages for tenant {tenant_id}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def send_grace_period_reminders() -> dict:
|
||||||
|
"""
|
||||||
|
Send reminder emails for overages approaching grace period end.
|
||||||
|
Call this from a daily Celery task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with counts of reminders sent
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
week_from_now = now + timedelta(days=7)
|
||||||
|
day_from_now = now + timedelta(days=1)
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'week_reminders_sent': 0,
|
||||||
|
'day_reminders_sent': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7-day reminders
|
||||||
|
week_overages = QuotaOverage.objects.filter(
|
||||||
|
status='ACTIVE',
|
||||||
|
week_reminder_sent_at__isnull=True,
|
||||||
|
grace_period_ends_at__lte=week_from_now,
|
||||||
|
grace_period_ends_at__gt=day_from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
for overage in week_overages:
|
||||||
|
try:
|
||||||
|
service = QuotaService(overage.tenant)
|
||||||
|
service.send_overage_notification(overage, 'week_reminder')
|
||||||
|
results['week_reminders_sent'] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending week reminder for overage {overage.id}: {e}")
|
||||||
|
|
||||||
|
# 1-day reminders
|
||||||
|
day_overages = QuotaOverage.objects.filter(
|
||||||
|
status='ACTIVE',
|
||||||
|
day_reminder_sent_at__isnull=True,
|
||||||
|
grace_period_ends_at__lte=day_from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
for overage in day_overages:
|
||||||
|
try:
|
||||||
|
service = QuotaService(overage.tenant)
|
||||||
|
service.send_overage_notification(overage, 'day_reminder')
|
||||||
|
results['day_reminders_sent'] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending day reminder for overage {overage.id}: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
273
smoothschedule/core/tasks.py
Normal file
273
smoothschedule/core/tasks.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
Celery tasks for quota management and grace period enforcement.
|
||||||
|
|
||||||
|
These tasks run periodically to:
|
||||||
|
1. Check for new quota overages across all tenants
|
||||||
|
2. Send reminder emails (7 days, 1 day before grace period ends)
|
||||||
|
3. Auto-archive resources when grace period expires
|
||||||
|
"""
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def check_all_tenant_quotas():
|
||||||
|
"""
|
||||||
|
Check all tenants for quota overages and create QuotaOverage records.
|
||||||
|
|
||||||
|
This task should run daily (or after plan changes) to detect new overages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of overages found and notifications sent
|
||||||
|
"""
|
||||||
|
from smoothschedule.tenants.models import Tenant
|
||||||
|
from .quota_service import check_tenant_quotas
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'tenants_checked': 0,
|
||||||
|
'new_overages': 0,
|
||||||
|
'notifications_sent': 0,
|
||||||
|
'errors': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all active tenants
|
||||||
|
tenants = Tenant.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
for tenant in tenants:
|
||||||
|
try:
|
||||||
|
results['tenants_checked'] += 1
|
||||||
|
overages = check_tenant_quotas(tenant)
|
||||||
|
|
||||||
|
for overage in overages:
|
||||||
|
results['new_overages'] += 1
|
||||||
|
if overage.initial_email_sent_at:
|
||||||
|
results['notifications_sent'] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error checking tenant {tenant.id}: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
results['errors'].append(error_msg)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Quota check complete: {results['tenants_checked']} tenants checked, "
|
||||||
|
f"{results['new_overages']} new overages, "
|
||||||
|
f"{results['notifications_sent']} notifications sent"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_quota_reminder_emails():
|
||||||
|
"""
|
||||||
|
Send reminder emails for active quota overages approaching deadline.
|
||||||
|
|
||||||
|
Sends:
|
||||||
|
- 7-day reminder (if not already sent)
|
||||||
|
- 1-day reminder (if not already sent)
|
||||||
|
|
||||||
|
This task should run daily.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of reminders sent
|
||||||
|
"""
|
||||||
|
from .quota_service import send_grace_period_reminders
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'week_reminders_sent': 0,
|
||||||
|
'day_reminders_sent': 0,
|
||||||
|
'errors': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
reminder_results = send_grace_period_reminders()
|
||||||
|
results['week_reminders_sent'] = reminder_results.get('week_reminders_sent', 0)
|
||||||
|
results['day_reminders_sent'] = reminder_results.get('day_reminders_sent', 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error sending reminder emails: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
results['errors'].append(error_msg)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Quota reminders sent: {results['week_reminders_sent']} week reminders, "
|
||||||
|
f"{results['day_reminders_sent']} day reminders"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_expired_quotas():
|
||||||
|
"""
|
||||||
|
Process quota overages where grace period has expired.
|
||||||
|
|
||||||
|
Auto-archives oldest resources for each expired overage.
|
||||||
|
|
||||||
|
This task should run daily, preferably early morning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Summary of auto-archiving actions taken
|
||||||
|
"""
|
||||||
|
from .quota_service import process_expired_grace_periods
|
||||||
|
|
||||||
|
results = {
|
||||||
|
'overages_processed': 0,
|
||||||
|
'resources_archived': 0,
|
||||||
|
'errors': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive_results = process_expired_grace_periods()
|
||||||
|
results['overages_processed'] = archive_results.get('overages_processed', 0)
|
||||||
|
results['resources_archived'] = archive_results.get('total_archived', 0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error processing expired quotas: {str(e)}"
|
||||||
|
logger.error(error_msg, exc_info=True)
|
||||||
|
results['errors'].append(error_msg)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Expired quotas processed: {results['overages_processed']} overages, "
|
||||||
|
f"{results['resources_archived']} resources archived"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def check_single_tenant_quotas(tenant_id: int):
|
||||||
|
"""
|
||||||
|
Check quotas for a single tenant.
|
||||||
|
|
||||||
|
Use this when a tenant changes plans (upgrade/downgrade).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: ID of the tenant to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Overages found for this tenant
|
||||||
|
"""
|
||||||
|
from smoothschedule.tenants.models import Tenant
|
||||||
|
from .quota_service import check_tenant_quotas
|
||||||
|
|
||||||
|
try:
|
||||||
|
tenant = Tenant.objects.get(id=tenant_id)
|
||||||
|
overages = check_tenant_quotas(tenant)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'tenant_id': tenant_id,
|
||||||
|
'tenant_name': tenant.name,
|
||||||
|
'overages_found': len(overages),
|
||||||
|
'overages': [
|
||||||
|
{
|
||||||
|
'quota_type': o.quota_type,
|
||||||
|
'current_usage': o.current_usage,
|
||||||
|
'allowed_limit': o.allowed_limit,
|
||||||
|
'overage_amount': o.overage_amount,
|
||||||
|
'grace_period_ends_at': o.grace_period_ends_at.isoformat(),
|
||||||
|
}
|
||||||
|
for o in overages
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
except Tenant.DoesNotExist:
|
||||||
|
logger.error(f"Tenant {tenant_id} not found")
|
||||||
|
return {'error': f'Tenant {tenant_id} not found'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking tenant {tenant_id}: {str(e)}", exc_info=True)
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def resolve_tenant_overage(overage_id: int):
|
||||||
|
"""
|
||||||
|
Check if a specific quota overage has been resolved.
|
||||||
|
|
||||||
|
Call this after a tenant archives resources or upgrades plan.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
overage_id: ID of the QuotaOverage to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Resolution status
|
||||||
|
"""
|
||||||
|
from .models import QuotaOverage
|
||||||
|
from .quota_service import QuotaService
|
||||||
|
|
||||||
|
try:
|
||||||
|
overage = QuotaOverage.objects.select_related('tenant').get(id=overage_id)
|
||||||
|
|
||||||
|
if overage.status != 'ACTIVE':
|
||||||
|
return {
|
||||||
|
'overage_id': overage_id,
|
||||||
|
'already_resolved': True,
|
||||||
|
'status': overage.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current usage
|
||||||
|
service = QuotaService(overage.tenant)
|
||||||
|
current_usage = service.get_current_usage(overage.quota_type)
|
||||||
|
allowed_limit = service.get_limit(overage.quota_type)
|
||||||
|
|
||||||
|
if current_usage <= allowed_limit:
|
||||||
|
# Overage resolved!
|
||||||
|
overage.resolve()
|
||||||
|
return {
|
||||||
|
'overage_id': overage_id,
|
||||||
|
'resolved': True,
|
||||||
|
'current_usage': current_usage,
|
||||||
|
'allowed_limit': allowed_limit,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Still over limit
|
||||||
|
overage.current_usage = current_usage
|
||||||
|
overage.overage_amount = current_usage - allowed_limit
|
||||||
|
overage.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'overage_id': overage_id,
|
||||||
|
'resolved': False,
|
||||||
|
'current_usage': current_usage,
|
||||||
|
'allowed_limit': allowed_limit,
|
||||||
|
'still_over_by': current_usage - allowed_limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
except QuotaOverage.DoesNotExist:
|
||||||
|
logger.error(f"QuotaOverage {overage_id} not found")
|
||||||
|
return {'error': f'QuotaOverage {overage_id} not found'}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resolving overage {overage_id}: {str(e)}", exc_info=True)
|
||||||
|
return {'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_old_resolved_overages(days_to_keep: int = 90):
|
||||||
|
"""
|
||||||
|
Clean up old resolved/archived quota overage records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_to_keep: Keep records from the last N days (default: 90)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Number of records deleted
|
||||||
|
"""
|
||||||
|
from .models import QuotaOverage
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff_date = timezone.now() - timedelta(days=days_to_keep)
|
||||||
|
|
||||||
|
# Only delete resolved or archived overages
|
||||||
|
deleted_count, _ = QuotaOverage.objects.filter(
|
||||||
|
status__in=['RESOLVED', 'ARCHIVED', 'CANCELLED'],
|
||||||
|
updated_at__lt=cutoff_date
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
logger.info(f"Deleted {deleted_count} old quota overage records")
|
||||||
|
return deleted_count
|
||||||
@@ -24,7 +24,7 @@ class Command(BaseCommand):
|
|||||||
force = options['force']
|
force = options['force']
|
||||||
|
|
||||||
plans = [
|
plans = [
|
||||||
# Free Tier
|
# Free Tier - No payments included (requires Payments add-on)
|
||||||
{
|
{
|
||||||
'name': 'Free',
|
'name': 'Free',
|
||||||
'description': 'Perfect for getting started. Try out the core features with no commitment.',
|
'description': 'Perfect for getting started. Try out the core features with no commitment.',
|
||||||
@@ -50,7 +50,7 @@ class Command(BaseCommand):
|
|||||||
'max_email_templates': 3,
|
'max_email_templates': 3,
|
||||||
},
|
},
|
||||||
'permissions': {
|
'permissions': {
|
||||||
'can_accept_payments': False,
|
'can_accept_payments': False, # Requires Payments add-on
|
||||||
'sms_reminders': False,
|
'sms_reminders': False,
|
||||||
'advanced_reporting': False,
|
'advanced_reporting': False,
|
||||||
'priority_support': False,
|
'priority_support': False,
|
||||||
@@ -63,7 +63,7 @@ class Command(BaseCommand):
|
|||||||
'can_customize_booking_page': False,
|
'can_customize_booking_page': False,
|
||||||
'can_export_data': False,
|
'can_export_data': False,
|
||||||
},
|
},
|
||||||
'transaction_fee_percent': 0,
|
'transaction_fee_percent': 0, # No payments
|
||||||
'transaction_fee_fixed': 0,
|
'transaction_fee_fixed': 0,
|
||||||
'sms_enabled': False,
|
'sms_enabled': False,
|
||||||
'masked_calling_enabled': False,
|
'masked_calling_enabled': False,
|
||||||
@@ -73,7 +73,7 @@ class Command(BaseCommand):
|
|||||||
'is_most_popular': False,
|
'is_most_popular': False,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
# Starter Tier
|
# Starter Tier - Stripe charges 2.9% + $0.30, we charge 4% + $0.40
|
||||||
{
|
{
|
||||||
'name': 'Starter',
|
'name': 'Starter',
|
||||||
'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.',
|
'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.',
|
||||||
@@ -85,7 +85,7 @@ class Command(BaseCommand):
|
|||||||
'Up to 5 team members',
|
'Up to 5 team members',
|
||||||
'Up to 15 resources',
|
'Up to 15 resources',
|
||||||
'Unlimited appointments',
|
'Unlimited appointments',
|
||||||
'Online payments (2.9% + $0.30)',
|
'Online payments (4% + $0.40)',
|
||||||
'SMS reminders',
|
'SMS reminders',
|
||||||
'Custom booking page colors',
|
'Custom booking page colors',
|
||||||
'Google Calendar sync',
|
'Google Calendar sync',
|
||||||
@@ -114,8 +114,8 @@ class Command(BaseCommand):
|
|||||||
'can_customize_booking_page': True,
|
'can_customize_booking_page': True,
|
||||||
'can_export_data': True,
|
'can_export_data': True,
|
||||||
},
|
},
|
||||||
'transaction_fee_percent': 2.9,
|
'transaction_fee_percent': 4.0, # Stripe: 2.9%, our margin: 1.1%
|
||||||
'transaction_fee_fixed': 0.30,
|
'transaction_fee_fixed': 40, # 40 cents (Stripe: 30¢, our margin: 10¢)
|
||||||
'sms_enabled': True,
|
'sms_enabled': True,
|
||||||
'sms_price_per_message_cents': 3,
|
'sms_price_per_message_cents': 3,
|
||||||
'masked_calling_enabled': False,
|
'masked_calling_enabled': False,
|
||||||
@@ -125,7 +125,7 @@ class Command(BaseCommand):
|
|||||||
'is_most_popular': False,
|
'is_most_popular': False,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
# Professional Tier
|
# Professional Tier - Stripe charges 2.9% + $0.30, we charge 3.5% + $0.35
|
||||||
{
|
{
|
||||||
'name': 'Professional',
|
'name': 'Professional',
|
||||||
'description': 'For growing teams that need powerful automation and customization.',
|
'description': 'For growing teams that need powerful automation and customization.',
|
||||||
@@ -137,7 +137,7 @@ class Command(BaseCommand):
|
|||||||
'Up to 15 team members',
|
'Up to 15 team members',
|
||||||
'Unlimited resources',
|
'Unlimited resources',
|
||||||
'Unlimited appointments',
|
'Unlimited appointments',
|
||||||
'Lower payment fees (2.5% + $0.25)',
|
'Lower payment fees (3.5% + $0.35)',
|
||||||
'SMS & masked calling',
|
'SMS & masked calling',
|
||||||
'Custom domain',
|
'Custom domain',
|
||||||
'Advanced analytics',
|
'Advanced analytics',
|
||||||
@@ -168,8 +168,8 @@ class Command(BaseCommand):
|
|||||||
'can_customize_booking_page': True,
|
'can_customize_booking_page': True,
|
||||||
'can_export_data': True,
|
'can_export_data': True,
|
||||||
},
|
},
|
||||||
'transaction_fee_percent': 2.5,
|
'transaction_fee_percent': 3.5, # Stripe: 2.9%, our margin: 0.6%
|
||||||
'transaction_fee_fixed': 0.25,
|
'transaction_fee_fixed': 35, # 35 cents (Stripe: 30¢, our margin: 5¢)
|
||||||
'sms_enabled': True,
|
'sms_enabled': True,
|
||||||
'sms_price_per_message_cents': 3,
|
'sms_price_per_message_cents': 3,
|
||||||
'masked_calling_enabled': True,
|
'masked_calling_enabled': True,
|
||||||
@@ -184,7 +184,7 @@ class Command(BaseCommand):
|
|||||||
'is_most_popular': True,
|
'is_most_popular': True,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
# Business Tier
|
# Business Tier - Stripe charges 2.9% + $0.30, we charge 3.25% + $0.32
|
||||||
{
|
{
|
||||||
'name': 'Business',
|
'name': 'Business',
|
||||||
'description': 'For established businesses with multiple locations or large teams.',
|
'description': 'For established businesses with multiple locations or large teams.',
|
||||||
@@ -196,7 +196,7 @@ class Command(BaseCommand):
|
|||||||
'Up to 50 team members',
|
'Up to 50 team members',
|
||||||
'Unlimited resources',
|
'Unlimited resources',
|
||||||
'Unlimited appointments',
|
'Unlimited appointments',
|
||||||
'Lowest payment fees (2.2% + $0.20)',
|
'Lower payment fees (3.25% + $0.32)',
|
||||||
'All communication features',
|
'All communication features',
|
||||||
'Multiple custom domains',
|
'Multiple custom domains',
|
||||||
'White-label option',
|
'White-label option',
|
||||||
@@ -227,8 +227,8 @@ class Command(BaseCommand):
|
|||||||
'can_customize_booking_page': True,
|
'can_customize_booking_page': True,
|
||||||
'can_export_data': True,
|
'can_export_data': True,
|
||||||
},
|
},
|
||||||
'transaction_fee_percent': 2.2,
|
'transaction_fee_percent': 3.25, # Stripe: 2.9%, our margin: 0.35%
|
||||||
'transaction_fee_fixed': 0.20,
|
'transaction_fee_fixed': 32, # 32 cents (Stripe: 30¢, our margin: 2¢)
|
||||||
'sms_enabled': True,
|
'sms_enabled': True,
|
||||||
'sms_price_per_message_cents': 2,
|
'sms_price_per_message_cents': 2,
|
||||||
'masked_calling_enabled': True,
|
'masked_calling_enabled': True,
|
||||||
@@ -243,7 +243,7 @@ class Command(BaseCommand):
|
|||||||
'is_most_popular': False,
|
'is_most_popular': False,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
# Enterprise Tier
|
# Enterprise Tier - Stripe charges 2.9% + $0.30, we charge 3% + $0.30 (minimal margin)
|
||||||
{
|
{
|
||||||
'name': 'Enterprise',
|
'name': 'Enterprise',
|
||||||
'description': 'Custom solutions for large organizations with complex needs.',
|
'description': 'Custom solutions for large organizations with complex needs.',
|
||||||
@@ -254,7 +254,7 @@ class Command(BaseCommand):
|
|||||||
'features': [
|
'features': [
|
||||||
'Unlimited team members',
|
'Unlimited team members',
|
||||||
'Unlimited everything',
|
'Unlimited everything',
|
||||||
'Custom payment terms',
|
'Lowest payment fees (3% + $0.30)',
|
||||||
'Dedicated infrastructure',
|
'Dedicated infrastructure',
|
||||||
'Custom integrations',
|
'Custom integrations',
|
||||||
'SSO/SAML support',
|
'SSO/SAML support',
|
||||||
@@ -287,8 +287,8 @@ class Command(BaseCommand):
|
|||||||
'sso_enabled': True,
|
'sso_enabled': True,
|
||||||
'dedicated_support': True,
|
'dedicated_support': True,
|
||||||
},
|
},
|
||||||
'transaction_fee_percent': 2.0,
|
'transaction_fee_percent': 3.0, # Stripe: 2.9%, our margin: 0.1%
|
||||||
'transaction_fee_fixed': 0.15,
|
'transaction_fee_fixed': 30, # 30 cents (Stripe: 30¢, break-even)
|
||||||
'sms_enabled': True,
|
'sms_enabled': True,
|
||||||
'sms_price_per_message_cents': 2,
|
'sms_price_per_message_cents': 2,
|
||||||
'masked_calling_enabled': True,
|
'masked_calling_enabled': True,
|
||||||
@@ -325,9 +325,36 @@ class Command(BaseCommand):
|
|||||||
'is_public': True,
|
'is_public': True,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
|
# SMS Notifications Add-on - enables SMS reminders for tiers without it
|
||||||
|
{
|
||||||
|
'name': 'SMS Notifications',
|
||||||
|
'description': 'Send SMS appointment reminders and notifications to your customers.',
|
||||||
|
'plan_type': 'addon',
|
||||||
|
'business_tier': '', # Available to any tier without SMS
|
||||||
|
'price_monthly': 10.00,
|
||||||
|
'price_yearly': 100.00,
|
||||||
|
'features': [
|
||||||
|
'SMS appointment reminders',
|
||||||
|
'Custom SMS templates',
|
||||||
|
'Two-way SMS messaging',
|
||||||
|
'Pay-as-you-go credits ($0.03/msg)',
|
||||||
|
],
|
||||||
|
'limits': {},
|
||||||
|
'permissions': {
|
||||||
|
'sms_reminders': True,
|
||||||
|
},
|
||||||
|
'sms_enabled': True,
|
||||||
|
'sms_price_per_message_cents': 3,
|
||||||
|
'transaction_fee_percent': 0,
|
||||||
|
'transaction_fee_fixed': 0,
|
||||||
|
'is_active': True,
|
||||||
|
'is_public': True,
|
||||||
|
'show_price': True,
|
||||||
|
# Note: Only show to businesses without sms_reminders permission
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'name': 'SMS Bundle',
|
'name': 'SMS Bundle',
|
||||||
'description': 'Bulk SMS credits at a discounted rate.',
|
'description': 'Bulk SMS credits at a discounted rate. Requires SMS Notifications.',
|
||||||
'plan_type': 'addon',
|
'plan_type': 'addon',
|
||||||
'business_tier': '',
|
'business_tier': '',
|
||||||
'price_monthly': 20.00,
|
'price_monthly': 20.00,
|
||||||
@@ -346,6 +373,37 @@ class Command(BaseCommand):
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'is_public': True,
|
'is_public': True,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
|
# Note: Only show to businesses with sms_reminders permission
|
||||||
|
},
|
||||||
|
# Masked Calling Add-on - enables anonymous calling between customers and staff
|
||||||
|
{
|
||||||
|
'name': 'Masked Calling',
|
||||||
|
'description': 'Enable anonymous phone calls between your customers and staff.',
|
||||||
|
'plan_type': 'addon',
|
||||||
|
'business_tier': '', # Available to any tier without masked calling
|
||||||
|
'price_monthly': 15.00,
|
||||||
|
'price_yearly': 150.00,
|
||||||
|
'features': [
|
||||||
|
'Anonymous customer-staff calls',
|
||||||
|
'Privacy protection for both parties',
|
||||||
|
'Call recording (optional)',
|
||||||
|
'Pay-as-you-go minutes ($0.05/min)',
|
||||||
|
'Includes 1 proxy phone number',
|
||||||
|
],
|
||||||
|
'limits': {},
|
||||||
|
'permissions': {
|
||||||
|
'can_use_masked_phone_numbers': True,
|
||||||
|
},
|
||||||
|
'masked_calling_enabled': True,
|
||||||
|
'masked_calling_price_per_minute_cents': 5,
|
||||||
|
'proxy_number_enabled': True,
|
||||||
|
'proxy_number_monthly_fee_cents': 0, # First number included
|
||||||
|
'transaction_fee_percent': 0,
|
||||||
|
'transaction_fee_fixed': 0,
|
||||||
|
'is_active': True,
|
||||||
|
'is_public': True,
|
||||||
|
'show_price': True,
|
||||||
|
# Note: Only show to businesses without can_use_masked_phone_numbers permission
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Additional Proxy Number',
|
'name': 'Additional Proxy Number',
|
||||||
@@ -368,6 +426,8 @@ class Command(BaseCommand):
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'is_public': True,
|
'is_public': True,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
|
# Note: Only show to businesses WITH can_use_masked_phone_numbers permission
|
||||||
|
# (either from tier or Masked Calling addon)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'White Label',
|
'name': 'White Label',
|
||||||
@@ -391,6 +451,34 @@ class Command(BaseCommand):
|
|||||||
'is_public': True,
|
'is_public': True,
|
||||||
'show_price': True,
|
'show_price': True,
|
||||||
},
|
},
|
||||||
|
# Online Payments Add-on - for Free tier users who want payment processing
|
||||||
|
# Covers Stripe's $2/mo connected account fee + transaction fees
|
||||||
|
{
|
||||||
|
'name': 'Online Payments',
|
||||||
|
'description': 'Accept online payments from your customers. For businesses on Free tier.',
|
||||||
|
'plan_type': 'addon',
|
||||||
|
'business_tier': '', # Available to any tier without payments
|
||||||
|
'price_monthly': 5.00,
|
||||||
|
'price_yearly': 50.00,
|
||||||
|
'features': [
|
||||||
|
'Accept credit/debit cards',
|
||||||
|
'Stripe Connect integration',
|
||||||
|
'Automatic payouts',
|
||||||
|
'Payment analytics',
|
||||||
|
'5% + $0.50 per transaction',
|
||||||
|
],
|
||||||
|
'limits': {},
|
||||||
|
'permissions': {
|
||||||
|
'can_accept_payments': True,
|
||||||
|
},
|
||||||
|
'transaction_fee_percent': 5.0, # Stripe: 2.9%, our margin: 2.1% (covers $2/mo account fee)
|
||||||
|
'transaction_fee_fixed': 50, # 50 cents (Stripe: 30¢, our margin: 20¢)
|
||||||
|
'is_active': True,
|
||||||
|
'is_public': True,
|
||||||
|
'show_price': True,
|
||||||
|
# Note: This addon should only be shown to businesses without can_accept_payments
|
||||||
|
# The frontend/backend should filter visibility based on current permissions
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-02 17:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0010_subscriptionplan_default_auto_reload_amount_cents_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='business_tier',
|
||||||
|
field=models.CharField(blank=True, choices=[('', 'N/A (Add-on)'), ('Free', 'Free'), ('Starter', 'Starter'), ('Professional', 'Professional'), ('Business', 'Business'), ('Enterprise', 'Enterprise')], default='', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -205,16 +205,19 @@ class SubscriptionPlan(models.Model):
|
|||||||
help_text="Yearly price in dollars"
|
help_text="Yearly price in dollars"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Business tier this plan corresponds to
|
# Business tier this plan corresponds to (empty for addons)
|
||||||
business_tier = models.CharField(
|
business_tier = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
choices=[
|
choices=[
|
||||||
('FREE', 'Free'),
|
('', 'N/A (Add-on)'),
|
||||||
('STARTER', 'Starter'),
|
('Free', 'Free'),
|
||||||
('PROFESSIONAL', 'Professional'),
|
('Starter', 'Starter'),
|
||||||
('ENTERPRISE', 'Enterprise'),
|
('Professional', 'Professional'),
|
||||||
|
('Business', 'Business'),
|
||||||
|
('Enterprise', 'Enterprise'),
|
||||||
],
|
],
|
||||||
default='PROFESSIONAL'
|
blank=True,
|
||||||
|
default=''
|
||||||
)
|
)
|
||||||
|
|
||||||
# Features included (stored as JSON array of strings)
|
# Features included (stored as JSON array of strings)
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
'price_monthly', 'price_yearly', 'business_tier',
|
'price_monthly', 'price_yearly', 'business_tier',
|
||||||
'features', 'limits', 'permissions',
|
'features', 'limits', 'permissions',
|
||||||
'transaction_fee_percent', 'transaction_fee_fixed',
|
'transaction_fee_percent', 'transaction_fee_fixed',
|
||||||
|
# SMS & Communication Settings
|
||||||
|
'sms_enabled', 'sms_price_per_message_cents',
|
||||||
|
# Masked Calling Settings
|
||||||
|
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
||||||
|
# Proxy Number Settings
|
||||||
|
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
||||||
|
# Default Credit Settings
|
||||||
|
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
||||||
|
'default_auto_reload_amount_cents',
|
||||||
|
# Status flags
|
||||||
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
@@ -129,6 +139,16 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
|
|||||||
'price_monthly', 'price_yearly', 'business_tier',
|
'price_monthly', 'price_yearly', 'business_tier',
|
||||||
'features', 'limits', 'permissions',
|
'features', 'limits', 'permissions',
|
||||||
'transaction_fee_percent', 'transaction_fee_fixed',
|
'transaction_fee_percent', 'transaction_fee_fixed',
|
||||||
|
# SMS & Communication Settings
|
||||||
|
'sms_enabled', 'sms_price_per_message_cents',
|
||||||
|
# Masked Calling Settings
|
||||||
|
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
||||||
|
# Proxy Number Settings
|
||||||
|
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
||||||
|
# Default Credit Settings
|
||||||
|
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
||||||
|
'default_auto_reload_amount_cents',
|
||||||
|
# Status flags
|
||||||
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
||||||
'create_stripe_product'
|
'create_stripe_product'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-02 16:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0023_email_template'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='resource',
|
||||||
|
name='archived_by_quota_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When this resource was archived due to quota overage', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='resource',
|
||||||
|
name='is_archived_by_quota',
|
||||||
|
field=models.BooleanField(default=False, help_text='True if this resource was archived due to quota overage'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='archived_by_quota_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When this service was archived due to quota overage', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='service',
|
||||||
|
name='is_archived_by_quota',
|
||||||
|
field=models.BooleanField(default=False, help_text='True if this service was archived due to quota overage'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -33,6 +33,18 @@ class Service(models.Model):
|
|||||||
help_text="List of photo URLs in display order"
|
help_text="List of photo URLs in display order"
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Quota overage archiving
|
||||||
|
is_archived_by_quota = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if this service was archived due to quota overage"
|
||||||
|
)
|
||||||
|
archived_by_quota_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When this service was archived due to quota overage"
|
||||||
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -152,11 +164,22 @@ class Resource(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# Quota overage archiving
|
||||||
|
is_archived_by_quota = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if this resource was archived due to quota overage"
|
||||||
|
)
|
||||||
|
archived_by_quota_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When this resource was archived due to quota overage"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
indexes = [models.Index(fields=['is_active', 'name'])]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
cap = "Unlimited" if self.max_concurrent_events == 0 else f"{self.max_concurrent_events} concurrent"
|
cap = "Unlimited" if self.max_concurrent_events == 0 else f"{self.max_concurrent_events} concurrent"
|
||||||
return f"{self.name} ({cap})"
|
return f"{self.name} ({cap})"
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ def current_user_view(request):
|
|||||||
# Get business info if user has a tenant
|
# Get business info if user has a tenant
|
||||||
business_name = None
|
business_name = None
|
||||||
business_subdomain = None
|
business_subdomain = None
|
||||||
|
quota_overages = []
|
||||||
|
|
||||||
if user.tenant:
|
if user.tenant:
|
||||||
business_name = user.tenant.name
|
business_name = user.tenant.name
|
||||||
# user.tenant.subdomain does not exist. Fetch from domains relation.
|
# user.tenant.subdomain does not exist. Fetch from domains relation.
|
||||||
@@ -130,6 +132,16 @@ def current_user_view(request):
|
|||||||
else:
|
else:
|
||||||
business_subdomain = user.tenant.schema_name
|
business_subdomain = user.tenant.schema_name
|
||||||
|
|
||||||
|
# Check for active quota overages (for owners and managers)
|
||||||
|
if user.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||||
|
from core.quota_service import QuotaService
|
||||||
|
try:
|
||||||
|
service = QuotaService(user.tenant)
|
||||||
|
quota_overages = service.get_active_overages()
|
||||||
|
except Exception:
|
||||||
|
# Don't fail login if quota check fails
|
||||||
|
pass
|
||||||
|
|
||||||
# Map database roles to frontend roles
|
# Map database roles to frontend roles
|
||||||
role_mapping = {
|
role_mapping = {
|
||||||
'superuser': 'superuser',
|
'superuser': 'superuser',
|
||||||
@@ -160,6 +172,7 @@ def current_user_view(request):
|
|||||||
'permissions': user.permissions,
|
'permissions': user.permissions,
|
||||||
'can_invite_staff': user.can_invite_staff(),
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
'can_access_tickets': user.can_access_tickets(),
|
'can_access_tickets': user.can_access_tickets(),
|
||||||
|
'quota_overages': quota_overages,
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(user_data, status=status.HTTP_200_OK)
|
return Response(user_data, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-02 16:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0008_add_mfa_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='archived_by_quota_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When this user was archived due to quota overage', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='is_archived_by_quota',
|
||||||
|
field=models.BooleanField(default=False, help_text='True if this user was archived due to quota overage. Cannot log in while archived.'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -67,6 +67,17 @@ class User(AbstractUser):
|
|||||||
help_text="True for sandbox/test mode users - isolated from live data"
|
help_text="True for sandbox/test mode users - isolated from live data"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Quota overage archiving
|
||||||
|
is_archived_by_quota = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if this user was archived due to quota overage. Cannot log in while archived."
|
||||||
|
)
|
||||||
|
archived_by_quota_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When this user was archived due to quota overage"
|
||||||
|
)
|
||||||
|
|
||||||
# Additional profile fields
|
# Additional profile fields
|
||||||
phone = models.CharField(max_length=20, blank=True)
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
phone_verified = models.BooleanField(
|
phone_verified = models.BooleanField(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>URGENT: Final Day to Resolve Quota Overage</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">FINAL NOTICE</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Automatic archiving begins tomorrow</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
|
||||||
|
<p>Hi {{ owner.first_name|default:owner.username }},</p>
|
||||||
|
|
||||||
|
<p><strong>This is your final reminder.</strong> Your <strong>{{ tenant.name }}</strong> account is still over the limit for <strong>{{ display_name }}</strong>, and the grace period ends <strong>tomorrow</strong>.</p>
|
||||||
|
|
||||||
|
<div style="background: #fee2e2; border: 2px solid #dc2626; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #991b1b; font-size: 24px;">
|
||||||
|
<strong>⚠️ LESS THAN 24 HOURS</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #991b1b;">
|
||||||
|
Grace period ends: <strong>{{ grace_period_ends|date:"F j, Y" }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Current Usage:</span>
|
||||||
|
<strong style="color: #92400e;">{{ current_usage }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Your Plan Allows:</span>
|
||||||
|
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
|
||||||
|
<span style="color: #92400e;">Will Be Archived:</span>
|
||||||
|
<strong style="color: #dc2626;">{{ overage_amount }} {{ display_name }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="color: #dc2626; font-size: 18px; margin-top: 30px;">What happens tomorrow?</h2>
|
||||||
|
|
||||||
|
<p>If no action is taken, the <strong>{{ overage_amount }} oldest {{ display_name }}</strong> will be automatically archived:</p>
|
||||||
|
|
||||||
|
<ul style="padding-left: 20px; color: #6b7280;">
|
||||||
|
<li style="margin-bottom: 8px;">Archived items will become <strong>read-only</strong></li>
|
||||||
|
<li style="margin-bottom: 8px;">You will not be able to make changes to archived items</li>
|
||||||
|
<li style="margin-bottom: 8px;">Data may become <strong>permanently unrecoverable</strong> after archiving</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="background: #ecfdf5; border: 1px solid #10b981; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #065f46;">
|
||||||
|
<strong>💡 Last chance to export!</strong> Download your data now at <a href="{{ export_url }}" style="color: #059669;">{{ export_url }}</a> before any archiving occurs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ manage_url }}" style="display: inline-block; background: #dc2626; color: white; padding: 14px 35px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">Take Action Now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="{{ upgrade_url }}" style="color: #10b981; font-weight: 600;">Or upgrade your plan to keep all your {{ display_name }} →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
Need help? Contact our support team immediately and we'll do our best to assist you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
⚠️ FINAL NOTICE: Automatic Archiving Begins Tomorrow
|
||||||
|
|
||||||
|
Hi {{ owner.first_name|default:owner.username }},
|
||||||
|
|
||||||
|
This is your FINAL reminder. Your {{ tenant.name }} account is still over the limit for {{ display_name }}, and the grace period ends TOMORROW.
|
||||||
|
|
||||||
|
⚠️ LESS THAN 24 HOURS REMAINING
|
||||||
|
Grace period ends: {{ grace_period_ends|date:"F j, Y" }}
|
||||||
|
|
||||||
|
CURRENT STATUS
|
||||||
|
--------------
|
||||||
|
Current Usage: {{ current_usage }}
|
||||||
|
Your Plan Allows: {{ allowed_limit }}
|
||||||
|
Will Be Archived: {{ overage_amount }} {{ display_name }}
|
||||||
|
|
||||||
|
WHAT HAPPENS TOMORROW?
|
||||||
|
----------------------
|
||||||
|
If no action is taken, the {{ overage_amount }} oldest {{ display_name }} will be automatically archived:
|
||||||
|
|
||||||
|
• Archived items will become read-only
|
||||||
|
• You will not be able to make changes to archived items
|
||||||
|
• Data may become permanently unrecoverable after archiving
|
||||||
|
|
||||||
|
💡 LAST CHANCE TO EXPORT!
|
||||||
|
Download your data now: {{ export_url }}
|
||||||
|
|
||||||
|
TAKE ACTION NOW
|
||||||
|
---------------
|
||||||
|
Manage your quota: {{ manage_url }}
|
||||||
|
Upgrade your plan: {{ upgrade_url }}
|
||||||
|
|
||||||
|
Need help? Contact our support team immediately and we'll do our best to assist you.
|
||||||
|
|
||||||
|
---
|
||||||
|
SmoothSchedule
|
||||||
66
smoothschedule/templates/emails/quota_overage_initial.html
Normal file
66
smoothschedule/templates/emails/quota_overage_initial.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Action Required: Quota Exceeded</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">Action Required</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your account has exceeded its quota</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
|
||||||
|
<p>Hi {{ owner.first_name|default:owner.username }},</p>
|
||||||
|
|
||||||
|
<p>Your <strong>{{ tenant.name }}</strong> account has exceeded its limit for <strong>{{ display_name }}</strong>.</p>
|
||||||
|
|
||||||
|
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Current Usage:</span>
|
||||||
|
<strong style="color: #92400e;">{{ current_usage }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Your Plan Allows:</span>
|
||||||
|
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
|
||||||
|
<span style="color: #92400e;">Over Limit By:</span>
|
||||||
|
<strong style="color: #dc2626;">{{ overage_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="color: #1f2937; font-size: 18px; margin-top: 30px;">What happens now?</h2>
|
||||||
|
|
||||||
|
<p>You have <strong>{{ days_remaining }} days</strong> (until {{ grace_period_ends|date:"F j, Y" }}) to resolve this by:</p>
|
||||||
|
|
||||||
|
<ol style="padding-left: 20px;">
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Select which {{ display_name }} to keep active</strong> - You can choose which ones to archive (they'll become read-only but data is preserved)</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Upgrade your plan</strong> - Get more capacity for your growing business</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Delete excess {{ display_name }}</strong> - Remove ones you no longer need</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div style="background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; padding: 15px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; color: #991b1b;">
|
||||||
|
<strong>Important:</strong> After the grace period ends, the oldest {{ display_name }} will be automatically archived. You can download your data before then to keep a copy for your records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ manage_url }}" style="display: inline-block; background: #4f46e5; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-right: 10px;">Manage Quota</a>
|
||||||
|
<a href="{{ upgrade_url }}" style="display: inline-block; background: #10b981; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600;">Upgrade Plan</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px;">
|
||||||
|
Need to keep your data? <a href="{{ export_url }}" style="color: #4f46e5;">Export it now</a> before making changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
If you have any questions, please contact our support team. We're here to help!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
smoothschedule/templates/emails/quota_overage_initial.txt
Normal file
32
smoothschedule/templates/emails/quota_overage_initial.txt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
Action Required: Your {{ tenant.name }} Account Has Exceeded Its Quota
|
||||||
|
|
||||||
|
Hi {{ owner.first_name|default:owner.username }},
|
||||||
|
|
||||||
|
Your {{ tenant.name }} account has exceeded its limit for {{ display_name }}.
|
||||||
|
|
||||||
|
CURRENT STATUS
|
||||||
|
--------------
|
||||||
|
Current Usage: {{ current_usage }}
|
||||||
|
Your Plan Allows: {{ allowed_limit }}
|
||||||
|
Over Limit By: {{ overage_amount }}
|
||||||
|
|
||||||
|
WHAT HAPPENS NOW?
|
||||||
|
-----------------
|
||||||
|
You have {{ days_remaining }} days (until {{ grace_period_ends|date:"F j, Y" }}) to resolve this by:
|
||||||
|
|
||||||
|
1. Select which {{ display_name }} to keep active - You can choose which ones to archive (they'll become read-only but data is preserved)
|
||||||
|
|
||||||
|
2. Upgrade your plan - Get more capacity for your growing business
|
||||||
|
|
||||||
|
3. Delete excess {{ display_name }} - Remove ones you no longer need
|
||||||
|
|
||||||
|
IMPORTANT: After the grace period ends, the oldest {{ display_name }} will be automatically archived. You can download your data before then to keep a copy for your records.
|
||||||
|
|
||||||
|
Manage your quota: {{ manage_url }}
|
||||||
|
Upgrade your plan: {{ upgrade_url }}
|
||||||
|
Export your data: {{ export_url }}
|
||||||
|
|
||||||
|
If you have any questions, please contact our support team. We're here to help!
|
||||||
|
|
||||||
|
---
|
||||||
|
SmoothSchedule
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reminder: 7 Days Left to Resolve Quota Overage</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 30px; border-radius: 12px 12px 0 0; text-align: center;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 24px;">7 Days Remaining</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.9); margin: 10px 0 0 0;">Your quota overage grace period is ending soon</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
|
||||||
|
<p>Hi {{ owner.first_name|default:owner.username }},</p>
|
||||||
|
|
||||||
|
<p>This is a reminder that your <strong>{{ tenant.name }}</strong> account is still over the limit for <strong>{{ display_name }}</strong>.</p>
|
||||||
|
|
||||||
|
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Current Usage:</span>
|
||||||
|
<strong style="color: #92400e;">{{ current_usage }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span style="color: #92400e;">Your Plan Allows:</span>
|
||||||
|
<strong style="color: #92400e;">{{ allowed_limit }}</strong>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; border-top: 1px solid #f59e0b; padding-top: 10px;">
|
||||||
|
<span style="color: #92400e;">Over Limit By:</span>
|
||||||
|
<strong style="color: #dc2626;">{{ overage_amount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #fee2e2; border: 1px solid #ef4444; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
|
||||||
|
<p style="margin: 0 0 10px 0; color: #991b1b; font-size: 18px;">
|
||||||
|
<strong>⏰ {{ days_remaining }} days left</strong>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #991b1b;">
|
||||||
|
Grace period ends on <strong>{{ grace_period_ends|date:"F j, Y" }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="color: #1f2937; font-size: 18px; margin-top: 30px;">Take action now</h2>
|
||||||
|
|
||||||
|
<p>To avoid automatic archiving of your {{ display_name }}, please:</p>
|
||||||
|
|
||||||
|
<ol style="padding-left: 20px;">
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Choose which {{ display_name }} to keep</strong> - Select which ones to archive</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Upgrade your plan</strong> - Increase your limits</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Export your data</strong> - Download a copy for your records</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ manage_url }}" style="display: inline-block; background: #f59e0b; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-right: 10px;">Manage Now</a>
|
||||||
|
<a href="{{ upgrade_url }}" style="display: inline-block; background: #10b981; color: white; padding: 12px 30px; border-radius: 8px; text-decoration: none; font-weight: 600;">Upgrade Plan</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 30px; color: #6b7280; font-size: 14px;">
|
||||||
|
<strong>What happens if I don't take action?</strong><br>
|
||||||
|
After the grace period, the oldest {{ display_name }} will be automatically archived. Archived items become read-only, and data may become unrecoverable.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
|
||||||
|
|
||||||
|
<p style="color: #6b7280; font-size: 14px;">
|
||||||
|
Questions? Contact our support team - we're happy to help you find the best solution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
REMINDER: 7 Days Left to Resolve Quota Overage
|
||||||
|
|
||||||
|
Hi {{ owner.first_name|default:owner.username }},
|
||||||
|
|
||||||
|
This is a reminder that your {{ tenant.name }} account is still over the limit for {{ display_name }}.
|
||||||
|
|
||||||
|
CURRENT STATUS
|
||||||
|
--------------
|
||||||
|
Current Usage: {{ current_usage }}
|
||||||
|
Your Plan Allows: {{ allowed_limit }}
|
||||||
|
Over Limit By: {{ overage_amount }}
|
||||||
|
|
||||||
|
⏰ {{ days_remaining }} DAYS LEFT
|
||||||
|
Grace period ends on {{ grace_period_ends|date:"F j, Y" }}
|
||||||
|
|
||||||
|
TAKE ACTION NOW
|
||||||
|
---------------
|
||||||
|
To avoid automatic archiving of your {{ display_name }}, please:
|
||||||
|
|
||||||
|
1. Choose which {{ display_name }} to keep - Select which ones to archive
|
||||||
|
2. Upgrade your plan - Increase your limits
|
||||||
|
3. Export your data - Download a copy for your records
|
||||||
|
|
||||||
|
Manage Now: {{ manage_url }}
|
||||||
|
Upgrade Plan: {{ upgrade_url }}
|
||||||
|
Export Data: {{ export_url }}
|
||||||
|
|
||||||
|
WHAT HAPPENS IF I DON'T TAKE ACTION?
|
||||||
|
After the grace period, the oldest {{ display_name }} will be automatically archived. Archived items become read-only, and data may become unrecoverable.
|
||||||
|
|
||||||
|
Questions? Contact our support team - we're happy to help you find the best solution.
|
||||||
|
|
||||||
|
---
|
||||||
|
SmoothSchedule
|
||||||
Reference in New Issue
Block a user