(null);
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ category: 'OTHER' as 'STAFF' | 'OTHER',
+ iconName: '',
+ });
+
+ const isOwner = user.role === 'owner';
+
+ const openCreateModal = () => {
+ setEditingType(null);
+ setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
+ setIsModalOpen(true);
+ };
+
+ const openEditModal = (type: any) => {
+ setEditingType(type);
+ setFormData({
+ name: type.name,
+ description: type.description || '',
+ category: type.category,
+ iconName: type.icon_name || type.iconName || '',
+ });
+ setIsModalOpen(true);
+ };
+
+ const closeModal = () => {
+ setIsModalOpen(false);
+ setEditingType(null);
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ if (editingType) {
+ await updateResourceType.mutateAsync({
+ id: editingType.id,
+ updates: formData,
+ });
+ } else {
+ await createResourceType.mutateAsync(formData);
+ }
+ closeModal();
+ } catch (error) {
+ console.error('Failed to save resource type:', error);
+ }
+ };
+
+ const handleDelete = async (id: string, name: string) => {
+ if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
+ try {
+ await deleteResourceType.mutateAsync(id);
+ } catch (error: any) {
+ alert(error.response?.data?.error || 'Failed to delete resource type');
+ }
+ }
+ };
+
+ if (!isOwner) {
+ return (
+
+
+ Only the business owner can access these settings.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('settings.resourceTypes.title', 'Resource Types')}
+
+
+ Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment).
+
+
+
+ {/* Resource Types List */}
+
+
+
+
+ {t('settings.resourceTypes.list', 'Your Resource Types')}
+
+
+ {t('settings.resourceTypes.listDescription', 'Create categories to organize your resources.')}
+
+
+
+
+ {t('settings.addResourceType', 'Add Type')}
+
+
+
+ {isLoading ? (
+
+ ) : resourceTypes.length === 0 ? (
+
+
+
{t('settings.noResourceTypes', 'No custom resource types yet.')}
+
{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}
+
+ ) : (
+
+ {resourceTypes.map((type: any) => {
+ const isDefault = type.is_default || type.isDefault;
+ return (
+
+
+
+
+ {type.category === 'STAFF' ? : }
+
+
+
+ {type.name}
+ {isDefault && (
+
+ Default
+
+ )}
+
+
+ {type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
+
+ {type.description && (
+
+ {type.description}
+
+ )}
+
+
+
+
openEditModal(type)}
+ className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
+ title={t('common.edit', 'Edit')}
+ >
+
+
+ {!isDefault && (
+
handleDelete(type.id, type.name)}
+ disabled={deleteResourceType.isPending}
+ className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
+ title={t('common.delete', 'Delete')}
+ >
+
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Modal for Create/Edit */}
+ {isModalOpen && (
+
+
+
+
+ {editingType
+ ? t('settings.editResourceType', 'Edit Resource Type')
+ : t('settings.addResourceType', 'Add Resource Type')}
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ResourceTypesSettings;
diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx
new file mode 100644
index 0000000..fe301ee
--- /dev/null
+++ b/frontend/src/pages/settings/index.tsx
@@ -0,0 +1,24 @@
+/**
+ * Settings Pages Index
+ *
+ * Exports all settings sub-pages for routing.
+ */
+
+// Business Settings
+export { default as GeneralSettings } from './GeneralSettings';
+export { default as BrandingSettings } from './BrandingSettings';
+export { default as ResourceTypesSettings } from './ResourceTypesSettings';
+
+// Integrations
+export { default as DomainsSettings } from './DomainsSettings';
+export { default as ApiSettings } from './ApiSettings';
+
+// Access
+export { default as AuthenticationSettings } from './AuthenticationSettings';
+
+// Communication
+export { default as EmailSettings } from './EmailSettings';
+export { default as CommunicationSettings } from './CommunicationSettings';
+
+// Billing
+export { default as BillingSettings } from './BillingSettings';
diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py
index f266ac3..13844ec 100644
--- a/smoothschedule/config/settings/base.py
+++ b/smoothschedule/config/settings/base.py
@@ -104,6 +104,7 @@ LOCAL_APPS = [
"platform_admin.apps.PlatformAdminConfig",
"notifications", # New: Generic notification app
"tickets", # New: Support tickets app
+ "smoothschedule.comms_credits", # Communication credits and SMS/calling
# Your stuff: custom apps go here
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py
index 397d528..19bf9f5 100644
--- a/smoothschedule/config/settings/multitenancy.py
+++ b/smoothschedule/config/settings/multitenancy.py
@@ -48,6 +48,7 @@ SHARED_APPS = [
'tickets', # Ticket system - shared for platform support access
'notifications', # Notification system - shared for platform to notify tenants
'smoothschedule.public_api', # Public API v1 for third-party integrations
+ 'smoothschedule.comms_credits', # Communication credits (SMS/calling) - shared for billing
]
# Tenant-specific apps - Each tenant gets isolated data in their own schema
@@ -55,7 +56,6 @@ TENANT_APPS = [
'django.contrib.contenttypes', # Needed for tenant schemas
'schedule', # Resource scheduling with configurable concurrency
'payments', # Stripe Connect payments bridge
- 'communication', # Twilio masked communications
# Add your tenant-scoped business logic apps here:
# 'appointments',
# 'customers',
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index 087f21c..400841b 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -69,6 +69,8 @@ urlpatterns += [
path("", include("schedule.urls")),
# Payments API
path("payments/", include("payments.urls")),
+ # Communication Credits API
+ path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
# Tickets API
path("tickets/", include("tickets.urls")),
# Notifications API
diff --git a/smoothschedule/core/migrations/0011_tenant_twilio_phone_number_and_more.py b/smoothschedule/core/migrations/0011_tenant_twilio_phone_number_and_more.py
new file mode 100644
index 0000000..b58f255
--- /dev/null
+++ b/smoothschedule/core/migrations/0011_tenant_twilio_phone_number_and_more.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.2.8 on 2025-12-02 02:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0010_add_oauth_credential_model'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='twilio_phone_number',
+ field=models.CharField(blank=True, default='', help_text='Assigned Twilio phone number for this tenant', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='twilio_subaccount_auth_token',
+ field=models.CharField(blank=True, default='', help_text='Twilio Subaccount Auth Token', max_length=50),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='twilio_subaccount_sid',
+ field=models.CharField(blank=True, default='', help_text='Twilio Subaccount SID for this tenant', max_length=50),
+ ),
+ ]
diff --git a/smoothschedule/core/migrations/0012_tenant_can_use_sms_reminders.py b/smoothschedule/core/migrations/0012_tenant_can_use_sms_reminders.py
new file mode 100644
index 0000000..b45e8df
--- /dev/null
+++ b/smoothschedule/core/migrations/0012_tenant_can_use_sms_reminders.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.8 on 2025-12-02 02:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0011_tenant_twilio_phone_number_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='can_use_sms_reminders',
+ field=models.BooleanField(default=False, help_text='Whether this business can send SMS reminders to customers/staff'),
+ ),
+ ]
diff --git a/smoothschedule/core/migrations/0013_stripe_payment_fields.py b/smoothschedule/core/migrations/0013_stripe_payment_fields.py
new file mode 100644
index 0000000..ba20827
--- /dev/null
+++ b/smoothschedule/core/migrations/0013_stripe_payment_fields.py
@@ -0,0 +1,83 @@
+# Generated by Django 5.2.8 on 2025-12-02 06:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0012_tenant_can_use_sms_reminders'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='tenant',
+ name='payment_mode',
+ field=models.CharField(choices=[('none', 'Not Configured'), ('direct_api', 'Direct API Keys (Free Tier)'), ('connect', 'Stripe Connect (Paid Tiers)')], default='none', help_text='How this business accepts payments', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_api_key_account_id',
+ field=models.CharField(blank=True, default='', help_text='Stripe Account ID from validated keys', max_length=50),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_api_key_account_name',
+ field=models.CharField(blank=True, default='', help_text='Stripe Account name from validated keys', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_api_key_error',
+ field=models.TextField(blank=True, default='', help_text='Validation error message if keys are invalid'),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_api_key_status',
+ field=models.CharField(blank=True, choices=[('active', 'Active'), ('invalid', 'Invalid'), ('deprecated', 'Deprecated')], default='active', help_text='Status of stored API keys', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_api_key_validated_at',
+ field=models.DateTimeField(blank=True, help_text='When the API keys were last validated', null=True),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_charges_enabled',
+ field=models.BooleanField(default=False, help_text='Whether Stripe account can accept charges'),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_connect_id',
+ field=models.CharField(blank=True, default='', help_text='Stripe Connected Account ID (acct_xxx)', max_length=50),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_connect_status',
+ field=models.CharField(choices=[('pending', 'Pending'), ('onboarding', 'Onboarding'), ('active', 'Active'), ('restricted', 'Restricted'), ('rejected', 'Rejected')], default='pending', help_text='Status of Stripe Connect account', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_details_submitted',
+ field=models.BooleanField(default=False, help_text='Whether onboarding details have been submitted'),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_onboarding_complete',
+ field=models.BooleanField(default=False, help_text='Whether Stripe Connect onboarding is complete'),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_payouts_enabled',
+ field=models.BooleanField(default=False, help_text='Whether Stripe account can receive payouts'),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_publishable_key',
+ field=models.CharField(blank=True, default='', help_text='Stripe Publishable Key (pk_xxx)', max_length=255),
+ ),
+ migrations.AddField(
+ model_name='tenant',
+ name='stripe_secret_key',
+ field=models.CharField(blank=True, default='', help_text='Stripe Secret Key (sk_xxx) - Encrypted', max_length=255),
+ ),
+ ]
diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py
index ac69705..54905fb 100644
--- a/smoothschedule/core/models.py
+++ b/smoothschedule/core/models.py
@@ -155,6 +155,10 @@ class Tenant(TenantMixin):
default=False,
help_text="Whether this business can permanently delete data"
)
+ can_use_sms_reminders = models.BooleanField(
+ default=False,
+ help_text="Whether this business can send SMS reminders to customers/staff"
+ )
can_use_masked_phone_numbers = models.BooleanField(
default=False,
help_text="Whether this business can use masked phone numbers for privacy"
@@ -168,12 +172,127 @@ class Tenant(TenantMixin):
help_text="Whether this business can use the mobile app"
)
+ # Stripe Payment Configuration
+ payment_mode = models.CharField(
+ max_length=20,
+ choices=[
+ ('none', 'Not Configured'),
+ ('direct_api', 'Direct API Keys (Free Tier)'),
+ ('connect', 'Stripe Connect (Paid Tiers)'),
+ ],
+ default='none',
+ help_text="How this business accepts payments"
+ )
+
+ # Stripe Connect fields (for paid tier businesses)
+ stripe_connect_id = models.CharField(
+ max_length=50,
+ blank=True,
+ default='',
+ help_text="Stripe Connected Account ID (acct_xxx)"
+ )
+ stripe_connect_status = models.CharField(
+ max_length=20,
+ choices=[
+ ('pending', 'Pending'),
+ ('onboarding', 'Onboarding'),
+ ('active', 'Active'),
+ ('restricted', 'Restricted'),
+ ('rejected', 'Rejected'),
+ ],
+ default='pending',
+ help_text="Status of Stripe Connect account"
+ )
+ stripe_charges_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether Stripe account can accept charges"
+ )
+ stripe_payouts_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether Stripe account can receive payouts"
+ )
+ stripe_details_submitted = models.BooleanField(
+ default=False,
+ help_text="Whether onboarding details have been submitted"
+ )
+ stripe_onboarding_complete = models.BooleanField(
+ default=False,
+ help_text="Whether Stripe Connect onboarding is complete"
+ )
+
+ # Direct API Keys fields (for free tier businesses)
+ stripe_secret_key = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Secret Key (sk_xxx) - Encrypted"
+ )
+ stripe_publishable_key = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Publishable Key (pk_xxx)"
+ )
+ stripe_api_key_status = models.CharField(
+ max_length=20,
+ choices=[
+ ('active', 'Active'),
+ ('invalid', 'Invalid'),
+ ('deprecated', 'Deprecated'),
+ ],
+ default='active',
+ blank=True,
+ help_text="Status of stored API keys"
+ )
+ stripe_api_key_validated_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When the API keys were last validated"
+ )
+ stripe_api_key_account_id = models.CharField(
+ max_length=50,
+ blank=True,
+ default='',
+ help_text="Stripe Account ID from validated keys"
+ )
+ stripe_api_key_account_name = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Account name from validated keys"
+ )
+ stripe_api_key_error = models.TextField(
+ blank=True,
+ default='',
+ help_text="Validation error message if keys are invalid"
+ )
+
# Onboarding tracking
initial_setup_complete = models.BooleanField(
default=False,
help_text="Whether the business has completed initial onboarding"
)
+ # Twilio Integration (for SMS reminders and masked calling)
+ twilio_subaccount_sid = models.CharField(
+ max_length=50,
+ blank=True,
+ default='',
+ help_text="Twilio Subaccount SID for this tenant"
+ )
+ twilio_subaccount_auth_token = models.CharField(
+ max_length=50,
+ blank=True,
+ default='',
+ help_text="Twilio Subaccount Auth Token"
+ )
+ twilio_phone_number = models.CharField(
+ max_length=20,
+ blank=True,
+ default='',
+ help_text="Assigned Twilio phone number for this tenant"
+ )
+
# Sandbox/Test Mode
sandbox_schema_name = models.CharField(
max_length=63,
diff --git a/smoothschedule/core/permissions.py b/smoothschedule/core/permissions.py
index dce9b71..374236d 100644
--- a/smoothschedule/core/permissions.py
+++ b/smoothschedule/core/permissions.py
@@ -222,7 +222,10 @@ def HasQuota(feature_code):
'MAX_RESOURCES': 'schedule.Resource',
'MAX_USERS': 'users.User',
'MAX_EVENTS_PER_MONTH': 'schedule.Event',
- # Add more mappings as needed
+ 'MAX_SERVICES': 'schedule.Service',
+ 'MAX_APPOINTMENTS': 'schedule.Event',
+ 'MAX_EMAIL_TEMPLATES': 'schedule.EmailTemplate',
+ 'MAX_AUTOMATED_TASKS': 'schedule.ScheduledTask',
}
def has_permission(self, request, view):
@@ -269,8 +272,23 @@ def HasQuota(feature_code):
# Count current usage
# NOTE: django-tenants automatically scopes this query to tenant schema
- current_count = Model.objects.count()
-
+
+ # Special handling for monthly appointment limit
+ if feature_code == 'MAX_APPOINTMENTS':
+ from django.utils import timezone
+ from datetime import timedelta
+ # Count appointments in current month
+ now = timezone.now()
+ start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ next_month = start_of_month + timedelta(days=32)
+ start_of_next_month = next_month.replace(day=1)
+ current_count = Model.objects.filter(
+ start_time__gte=start_of_month,
+ start_time__lt=start_of_next_month
+ ).count()
+ else:
+ current_count = Model.objects.count()
+
# The "Hard Block": Enforce the limit
if current_count >= limit:
# Quota exceeded - deny the operation
@@ -280,7 +298,7 @@ def HasQuota(feature_code):
f"{feature_code.replace('MAX_', '').lower().replace('_', ' ')}. "
f"Please upgrade your subscription to add more."
)
-
+
# Quota available - allow the operation
return True
diff --git a/smoothschedule/payments/urls.py b/smoothschedule/payments/urls.py
index 0f60a68..1844bab 100644
--- a/smoothschedule/payments/urls.py
+++ b/smoothschedule/payments/urls.py
@@ -3,12 +3,58 @@ Payments App URLs
"""
from django.urls import path
from .views import (
+ # Config status
+ PaymentConfigStatusView,
+ # API Keys (Free Tier)
+ ApiKeysView,
+ ApiKeysValidateView,
+ ApiKeysRevalidateView,
+ ApiKeysDeleteView,
+ # Connect (Paid Tiers)
+ ConnectStatusView,
+ ConnectOnboardView,
+ ConnectRefreshLinkView,
+ ConnectAccountSessionView,
+ ConnectRefreshStatusView,
+ # Transactions
+ TransactionListView,
+ TransactionSummaryView,
+ StripeChargesView,
+ StripePayoutsView,
+ StripeBalanceView,
+ TransactionExportView,
+ # Payment operations
CreatePaymentIntentView,
TerminalConnectionTokenView,
RefundPaymentView,
)
urlpatterns = [
+ # Payment configuration status
+ path('config/status/', PaymentConfigStatusView.as_view(), name='payment-config-status'),
+
+ # API Keys endpoints (free tier)
+ path('api-keys/', ApiKeysView.as_view(), name='api-keys'),
+ path('api-keys/validate/', ApiKeysValidateView.as_view(), name='api-keys-validate'),
+ path('api-keys/revalidate/', ApiKeysRevalidateView.as_view(), name='api-keys-revalidate'),
+ path('api-keys/delete/', ApiKeysDeleteView.as_view(), name='api-keys-delete'),
+
+ # Connect endpoints (paid tiers)
+ path('connect/status/', ConnectStatusView.as_view(), name='connect-status'),
+ path('connect/onboard/', ConnectOnboardView.as_view(), name='connect-onboard'),
+ path('connect/refresh-link/', ConnectRefreshLinkView.as_view(), name='connect-refresh-link'),
+ path('connect/account-session/', ConnectAccountSessionView.as_view(), name='connect-account-session'),
+ path('connect/refresh-status/', ConnectRefreshStatusView.as_view(), name='connect-refresh-status'),
+
+ # Transaction endpoints
+ path('transactions/', TransactionListView.as_view(), name='transaction-list'),
+ path('transactions/summary/', TransactionSummaryView.as_view(), name='transaction-summary'),
+ path('transactions/charges/', StripeChargesView.as_view(), name='stripe-charges'),
+ path('transactions/payouts/', StripePayoutsView.as_view(), name='stripe-payouts'),
+ path('transactions/balance/', StripeBalanceView.as_view(), name='stripe-balance'),
+ path('transactions/export/', TransactionExportView.as_view(), name='transaction-export'),
+
+ # Payment operations (existing)
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
diff --git a/smoothschedule/payments/views.py b/smoothschedule/payments/views.py
index 974a4cf..4ec9726 100644
--- a/smoothschedule/payments/views.py
+++ b/smoothschedule/payments/views.py
@@ -1,59 +1,976 @@
"""
Payments App - DRF Views
-API endpoints for Stripe payment intent creation and terminal tokens.
+API endpoints for Stripe payment configuration, Connect accounts, and transactions.
"""
+import stripe
+from django.conf import settings
+from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from decimal import Decimal
from .services import get_stripe_service_for_tenant
+from .models import TransactionLink
from schedule.models import Event
+# ============================================================================
+# Payment Configuration Status
+# ============================================================================
+
+class PaymentConfigStatusView(APIView):
+ """
+ Get unified payment configuration status.
+
+ GET /payments/config/status/
+
+ Returns the complete payment setup for the business including:
+ - payment_mode: 'none', 'direct_api', or 'connect'
+ - tier: subscription tier
+ - can_accept_payments: whether payments are enabled
+ - api_keys: API key info (if using direct_api mode)
+ - connect_account: Connect account info (if using connect mode)
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ tenant = request.tenant
+
+ # Build API keys info if configured
+ api_keys = None
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
+ api_keys = {
+ 'id': tenant.id,
+ 'status': tenant.stripe_api_key_status,
+ 'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
+ 'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
+ 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
+ 'stripe_account_id': tenant.stripe_api_key_account_id,
+ 'stripe_account_name': tenant.stripe_api_key_account_name,
+ 'validation_error': tenant.stripe_api_key_error,
+ 'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ }
+
+ # Build Connect account info if configured
+ connect_account = None
+ if tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
+ connect_account = {
+ 'id': tenant.id,
+ 'business': tenant.id,
+ 'business_name': tenant.name,
+ 'business_subdomain': tenant.schema_name,
+ 'stripe_account_id': tenant.stripe_connect_id,
+ 'account_type': 'standard', # We use standard Connect accounts
+ 'status': tenant.stripe_connect_status,
+ 'charges_enabled': tenant.stripe_charges_enabled,
+ 'payouts_enabled': tenant.stripe_payouts_enabled,
+ 'details_submitted': tenant.stripe_details_submitted,
+ 'onboarding_complete': tenant.stripe_onboarding_complete,
+ 'onboarding_link': None,
+ 'onboarding_link_expires_at': None,
+ 'is_onboarding_link_valid': False,
+ 'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ }
+
+ # Determine if payments can be accepted
+ can_accept = False
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_api_key_status == 'active':
+ can_accept = True
+ elif tenant.payment_mode == 'connect' and tenant.stripe_charges_enabled:
+ can_accept = True
+
+ return Response({
+ 'payment_mode': tenant.payment_mode,
+ 'tier': tenant.subscription_tier,
+ 'can_accept_payments': can_accept and tenant.can_accept_payments,
+ 'api_keys': api_keys,
+ 'connect_account': connect_account,
+ })
+
+ def _mask_key(self, key):
+ """Mask a key showing only first 7 and last 4 characters."""
+ if not key:
+ return ''
+ if len(key) <= 12:
+ return '*' * len(key)
+ return key[:7] + '*' * (len(key) - 11) + key[-4:]
+
+
+# ============================================================================
+# API Keys Endpoints (Free Tier)
+# ============================================================================
+
+class ApiKeysView(APIView):
+ """
+ Manage Stripe API keys for direct integration (free tier).
+
+ GET /payments/api-keys/
+ POST /payments/api-keys/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get current API key configuration."""
+ tenant = request.tenant
+
+ if not tenant.stripe_secret_key:
+ return Response({
+ 'configured': False,
+ 'message': 'No API keys configured'
+ })
+
+ return Response({
+ 'configured': True,
+ 'id': tenant.id,
+ 'status': tenant.stripe_api_key_status,
+ 'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
+ 'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
+ 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
+ 'stripe_account_id': tenant.stripe_api_key_account_id,
+ 'stripe_account_name': tenant.stripe_api_key_account_name,
+ 'validation_error': tenant.stripe_api_key_error,
+ })
+
+ def post(self, request):
+ """Save and validate API keys."""
+ secret_key = request.data.get('secret_key', '').strip()
+ publishable_key = request.data.get('publishable_key', '').strip()
+
+ if not secret_key or not publishable_key:
+ return Response(
+ {'error': 'Both secret_key and publishable_key are required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Validate keys against Stripe
+ validation = self._validate_keys(secret_key, publishable_key)
+
+ if not validation['valid']:
+ return Response(
+ {'error': validation.get('error', 'Invalid API keys')},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Save keys to tenant
+ tenant = request.tenant
+ tenant.stripe_secret_key = secret_key
+ tenant.stripe_publishable_key = publishable_key
+ tenant.stripe_api_key_status = 'active'
+ tenant.stripe_api_key_validated_at = timezone.now()
+ tenant.stripe_api_key_account_id = validation.get('account_id', '')
+ tenant.stripe_api_key_account_name = validation.get('account_name', '')
+ tenant.stripe_api_key_error = ''
+ tenant.payment_mode = 'direct_api'
+ tenant.save()
+
+ return Response({
+ 'id': tenant.id,
+ 'status': 'active',
+ 'secret_key_masked': self._mask_key(secret_key),
+ 'publishable_key_masked': self._mask_key(publishable_key),
+ 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
+ 'stripe_account_id': tenant.stripe_api_key_account_id,
+ 'stripe_account_name': tenant.stripe_api_key_account_name,
+ 'validation_error': '',
+ }, status=status.HTTP_201_CREATED)
+
+ def _validate_keys(self, secret_key, publishable_key):
+ """Validate Stripe API keys."""
+ try:
+ # Test the secret key by retrieving account info
+ stripe.api_key = secret_key
+ account = stripe.Account.retrieve()
+
+ # Verify publishable key format
+ if not publishable_key.startswith('pk_'):
+ return {'valid': False, 'error': 'Invalid publishable key format'}
+
+ # Determine environment
+ is_test = secret_key.startswith('sk_test_')
+
+ return {
+ 'valid': True,
+ 'account_id': account.id,
+ 'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
+ 'environment': 'test' if is_test else 'live',
+ }
+ except stripe.error.AuthenticationError:
+ return {'valid': False, 'error': 'Invalid secret key'}
+ except stripe.error.StripeError as e:
+ return {'valid': False, 'error': str(e)}
+ finally:
+ # Reset to platform key
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ def _mask_key(self, key):
+ """Mask a key showing only first 7 and last 4 characters."""
+ if not key:
+ return ''
+ if len(key) <= 12:
+ return '*' * len(key)
+ return key[:7] + '*' * (len(key) - 11) + key[-4:]
+
+
+class ApiKeysValidateView(APIView):
+ """
+ Validate API keys without saving.
+
+ POST /payments/api-keys/validate/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Validate keys without saving."""
+ secret_key = request.data.get('secret_key', '').strip()
+ publishable_key = request.data.get('publishable_key', '').strip()
+
+ if not secret_key or not publishable_key:
+ return Response(
+ {'valid': False, 'error': 'Both secret_key and publishable_key are required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ try:
+ stripe.api_key = secret_key
+ account = stripe.Account.retrieve()
+
+ if not publishable_key.startswith('pk_'):
+ return Response({
+ 'valid': False,
+ 'error': 'Invalid publishable key format'
+ })
+
+ is_test = secret_key.startswith('sk_test_')
+
+ return Response({
+ 'valid': True,
+ 'account_id': account.id,
+ 'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
+ 'environment': 'test' if is_test else 'live',
+ })
+ except stripe.error.AuthenticationError:
+ return Response({'valid': False, 'error': 'Invalid secret key'})
+ except stripe.error.StripeError as e:
+ return Response({'valid': False, 'error': str(e)})
+ finally:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class ApiKeysRevalidateView(APIView):
+ """
+ Re-validate stored API keys.
+
+ POST /payments/api-keys/revalidate/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Re-validate stored keys."""
+ tenant = request.tenant
+
+ if not tenant.stripe_secret_key:
+ return Response(
+ {'valid': False, 'error': 'No API keys configured'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ try:
+ stripe.api_key = tenant.stripe_secret_key
+ account = stripe.Account.retrieve()
+
+ # Update tenant with fresh validation
+ tenant.stripe_api_key_status = 'active'
+ tenant.stripe_api_key_validated_at = timezone.now()
+ tenant.stripe_api_key_account_id = account.id
+ tenant.stripe_api_key_account_name = account.get('business_profile', {}).get('name', '') or account.get('email', '')
+ tenant.stripe_api_key_error = ''
+ tenant.save()
+
+ return Response({
+ 'valid': True,
+ 'account_id': account.id,
+ 'account_name': tenant.stripe_api_key_account_name,
+ })
+ except stripe.error.AuthenticationError:
+ tenant.stripe_api_key_status = 'invalid'
+ tenant.stripe_api_key_error = 'Invalid secret key'
+ tenant.save()
+ return Response({'valid': False, 'error': 'Invalid secret key'})
+ except stripe.error.StripeError as e:
+ tenant.stripe_api_key_status = 'invalid'
+ tenant.stripe_api_key_error = str(e)
+ tenant.save()
+ return Response({'valid': False, 'error': str(e)})
+ finally:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class ApiKeysDeleteView(APIView):
+ """
+ Delete stored API keys.
+
+ DELETE /payments/api-keys/delete/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def delete(self, request):
+ """Delete stored keys."""
+ tenant = request.tenant
+
+ tenant.stripe_secret_key = ''
+ tenant.stripe_publishable_key = ''
+ tenant.stripe_api_key_status = ''
+ tenant.stripe_api_key_validated_at = None
+ tenant.stripe_api_key_account_id = ''
+ tenant.stripe_api_key_account_name = ''
+ tenant.stripe_api_key_error = ''
+ if tenant.payment_mode == 'direct_api':
+ tenant.payment_mode = 'none'
+ tenant.save()
+
+ return Response({
+ 'success': True,
+ 'message': 'API keys deleted successfully'
+ })
+
+
+# ============================================================================
+# Stripe Connect Endpoints (Paid Tiers)
+# ============================================================================
+
+class ConnectStatusView(APIView):
+ """
+ Get Connect account status.
+
+ GET /payments/connect/status/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get current Connect account status."""
+ tenant = request.tenant
+
+ if not tenant.stripe_connect_id:
+ return Response(
+ {'error': 'No Connect account configured'},
+ status=status.HTTP_404_NOT_FOUND
+ )
+
+ return Response({
+ 'id': tenant.id,
+ 'business': tenant.id,
+ 'business_name': tenant.name,
+ 'business_subdomain': tenant.schema_name,
+ 'stripe_account_id': tenant.stripe_connect_id,
+ 'account_type': 'standard',
+ 'status': tenant.stripe_connect_status,
+ 'charges_enabled': tenant.stripe_charges_enabled,
+ 'payouts_enabled': tenant.stripe_payouts_enabled,
+ 'details_submitted': tenant.stripe_details_submitted,
+ 'onboarding_complete': tenant.stripe_onboarding_complete,
+ 'onboarding_link': None,
+ 'onboarding_link_expires_at': None,
+ 'is_onboarding_link_valid': False,
+ 'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ })
+
+
+class ConnectOnboardView(APIView):
+ """
+ Initiate Connect account onboarding.
+
+ POST /payments/connect/onboard/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Start Connect onboarding flow."""
+ refresh_url = request.data.get('refresh_url')
+ return_url = request.data.get('return_url')
+
+ if not refresh_url or not return_url:
+ return Response(
+ {'error': 'refresh_url and return_url are required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ tenant = request.tenant
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ try:
+ # Create or retrieve Connect account
+ if not tenant.stripe_connect_id:
+ # Create new Standard Connect account
+ account = stripe.Account.create(
+ type='standard',
+ email=tenant.contact_email or None,
+ business_profile={
+ 'name': tenant.name,
+ },
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'tenant_schema': tenant.schema_name,
+ }
+ )
+ tenant.stripe_connect_id = account.id
+ tenant.stripe_connect_status = 'onboarding'
+ tenant.payment_mode = 'connect'
+ tenant.save()
+
+ # Create account link for onboarding
+ account_link = stripe.AccountLink.create(
+ account=tenant.stripe_connect_id,
+ refresh_url=refresh_url,
+ return_url=return_url,
+ type='account_onboarding',
+ )
+
+ return Response({
+ 'account_type': 'standard',
+ 'url': account_link.url,
+ 'stripe_account_id': tenant.stripe_connect_id,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+class ConnectRefreshLinkView(APIView):
+ """
+ Refresh Connect onboarding link.
+
+ POST /payments/connect/refresh-link/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Get a new onboarding link."""
+ refresh_url = request.data.get('refresh_url')
+ return_url = request.data.get('return_url')
+
+ if not refresh_url or not return_url:
+ return Response(
+ {'error': 'refresh_url and return_url are required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ tenant = request.tenant
+
+ if not tenant.stripe_connect_id:
+ return Response(
+ {'error': 'No Connect account exists'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ try:
+ account_link = stripe.AccountLink.create(
+ account=tenant.stripe_connect_id,
+ refresh_url=refresh_url,
+ return_url=return_url,
+ type='account_onboarding',
+ )
+
+ return Response({'url': account_link.url})
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+class ConnectAccountSessionView(APIView):
+ """
+ Create an Account Session for embedded Connect.
+
+ POST /payments/connect/account-session/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Create account session for embedded components."""
+ tenant = request.tenant
+
+ if not tenant.stripe_connect_id:
+ return Response(
+ {'error': 'No Connect account exists'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ try:
+ account_session = stripe.AccountSession.create(
+ account=tenant.stripe_connect_id,
+ components={
+ 'account_onboarding': {'enabled': True},
+ 'payments': {'enabled': True},
+ 'payouts': {'enabled': True},
+ },
+ )
+
+ return Response({
+ 'client_secret': account_session.client_secret,
+ 'stripe_account_id': tenant.stripe_connect_id,
+ 'publishable_key': settings.STRIPE_PUBLISHABLE_KEY,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+class ConnectRefreshStatusView(APIView):
+ """
+ Refresh Connect account status from Stripe.
+
+ POST /payments/connect/refresh-status/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Sync local status with Stripe."""
+ tenant = request.tenant
+
+ if not tenant.stripe_connect_id:
+ return Response(
+ {'error': 'No Connect account exists'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+ try:
+ account = stripe.Account.retrieve(tenant.stripe_connect_id)
+
+ # Update tenant with current status
+ tenant.stripe_charges_enabled = account.charges_enabled
+ tenant.stripe_payouts_enabled = account.payouts_enabled
+ tenant.stripe_details_submitted = account.details_submitted
+
+ # Determine status
+ if account.charges_enabled and account.payouts_enabled:
+ tenant.stripe_connect_status = 'active'
+ tenant.stripe_onboarding_complete = True
+ elif account.details_submitted:
+ tenant.stripe_connect_status = 'onboarding'
+ else:
+ tenant.stripe_connect_status = 'pending'
+
+ tenant.save()
+
+ return Response({
+ 'id': tenant.id,
+ 'business': tenant.id,
+ 'business_name': tenant.name,
+ 'business_subdomain': tenant.schema_name,
+ 'stripe_account_id': tenant.stripe_connect_id,
+ 'account_type': 'standard',
+ 'status': tenant.stripe_connect_status,
+ 'charges_enabled': tenant.stripe_charges_enabled,
+ 'payouts_enabled': tenant.stripe_payouts_enabled,
+ 'details_submitted': tenant.stripe_details_submitted,
+ 'onboarding_complete': tenant.stripe_onboarding_complete,
+ 'onboarding_link': None,
+ 'onboarding_link_expires_at': None,
+ 'is_onboarding_link_valid': False,
+ 'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ 'updated_at': tenant.created_on.isoformat() if tenant.created_on else None,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+# ============================================================================
+# Transaction Endpoints
+# ============================================================================
+
+class TransactionListView(APIView):
+ """
+ List transactions with filtering.
+
+ GET /payments/transactions/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get paginated list of transactions."""
+ # Get query params
+ page = int(request.query_params.get('page', 1))
+ page_size = int(request.query_params.get('page_size', 20))
+ status_filter = request.query_params.get('status')
+ transaction_type = request.query_params.get('transaction_type')
+ start_date = request.query_params.get('start_date')
+ end_date = request.query_params.get('end_date')
+
+ # Build queryset
+ queryset = TransactionLink.objects.all()
+
+ if status_filter:
+ queryset = queryset.filter(status=status_filter.upper())
+
+ if start_date:
+ queryset = queryset.filter(created_at__date__gte=start_date)
+
+ if end_date:
+ queryset = queryset.filter(created_at__date__lte=end_date)
+
+ # Paginate
+ total_count = queryset.count()
+ total_pages = (total_count + page_size - 1) // page_size
+ offset = (page - 1) * page_size
+ transactions = queryset[offset:offset + page_size]
+
+ # Serialize
+ results = []
+ for tx in transactions:
+ results.append({
+ 'id': tx.id,
+ 'business': request.tenant.id,
+ 'business_name': request.tenant.name,
+ 'stripe_payment_intent_id': tx.payment_intent_id,
+ 'stripe_charge_id': '',
+ 'transaction_type': 'payment',
+ 'status': tx.status.lower(),
+ 'amount': float(tx.amount),
+ 'amount_display': f'${tx.amount:.2f}',
+ 'application_fee_amount': float(tx.application_fee_amount),
+ 'fee_display': f'${tx.application_fee_amount:.2f}',
+ 'net_amount': float(tx.tenant_revenue),
+ 'currency': tx.currency,
+ 'customer_email': '',
+ 'customer_name': '',
+ 'created_at': tx.created_at.isoformat(),
+ 'updated_at': tx.updated_at.isoformat(),
+ })
+
+ return Response({
+ 'results': results,
+ 'count': total_count,
+ 'page': page,
+ 'page_size': page_size,
+ 'total_pages': total_pages,
+ })
+
+
+class TransactionSummaryView(APIView):
+ """
+ Get transaction summary/analytics.
+
+ GET /payments/transactions/summary/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get transaction summary."""
+ from django.db.models import Sum, Count, Avg
+
+ start_date = request.query_params.get('start_date')
+ end_date = request.query_params.get('end_date')
+
+ queryset = TransactionLink.objects.all()
+
+ if start_date:
+ queryset = queryset.filter(created_at__date__gte=start_date)
+ if end_date:
+ queryset = queryset.filter(created_at__date__lte=end_date)
+
+ # Calculate aggregates
+ stats = queryset.aggregate(
+ total_transactions=Count('id'),
+ total_volume=Sum('amount'),
+ total_fees=Sum('application_fee_amount'),
+ average_transaction=Avg('amount'),
+ )
+
+ successful = queryset.filter(status=TransactionLink.Status.SUCCEEDED).count()
+ failed = queryset.filter(status=TransactionLink.Status.FAILED).count()
+ refunded = queryset.filter(status=TransactionLink.Status.REFUNDED).count()
+
+ total_volume = stats['total_volume'] or Decimal('0')
+ total_fees = stats['total_fees'] or Decimal('0')
+ net_revenue = total_volume - total_fees
+ avg_tx = stats['average_transaction'] or Decimal('0')
+
+ return Response({
+ 'total_transactions': stats['total_transactions'] or 0,
+ 'total_volume': float(total_volume),
+ 'total_volume_display': f'${total_volume:.2f}',
+ 'total_fees': float(total_fees),
+ 'total_fees_display': f'${total_fees:.2f}',
+ 'net_revenue': float(net_revenue),
+ 'net_revenue_display': f'${net_revenue:.2f}',
+ 'successful_transactions': successful,
+ 'failed_transactions': failed,
+ 'refunded_transactions': refunded,
+ 'average_transaction': float(avg_tx),
+ 'average_transaction_display': f'${avg_tx:.2f}',
+ })
+
+
+class StripeChargesView(APIView):
+ """
+ Get recent charges from Stripe.
+
+ GET /payments/transactions/charges/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get recent charges from Stripe API."""
+ limit = int(request.query_params.get('limit', 20))
+ tenant = request.tenant
+
+ # Determine which Stripe key to use
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
+ stripe.api_key = tenant.stripe_secret_key
+ stripe_account = None
+ elif tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ stripe_account = tenant.stripe_connect_id
+ else:
+ return Response({
+ 'charges': [],
+ 'has_more': False,
+ })
+
+ try:
+ params = {'limit': limit}
+ if stripe_account:
+ params['stripe_account'] = stripe_account
+
+ charges = stripe.Charge.list(**params)
+
+ results = []
+ for charge in charges.data:
+ results.append({
+ 'id': charge.id,
+ 'amount': charge.amount,
+ 'amount_display': f'${charge.amount / 100:.2f}',
+ 'amount_refunded': charge.amount_refunded,
+ 'currency': charge.currency,
+ 'status': charge.status,
+ 'paid': charge.paid,
+ 'refunded': charge.refunded,
+ 'description': charge.description,
+ 'receipt_email': charge.receipt_email,
+ 'receipt_url': charge.receipt_url,
+ 'created': charge.created,
+ 'payment_method_details': dict(charge.payment_method_details) if charge.payment_method_details else None,
+ 'billing_details': dict(charge.billing_details) if charge.billing_details else None,
+ })
+
+ return Response({
+ 'charges': results,
+ 'has_more': charges.has_more,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+ finally:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class StripePayoutsView(APIView):
+ """
+ Get payouts from Stripe.
+
+ GET /payments/transactions/payouts/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get payouts from Stripe API."""
+ limit = int(request.query_params.get('limit', 20))
+ tenant = request.tenant
+
+ # Determine which Stripe key to use
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
+ stripe.api_key = tenant.stripe_secret_key
+ stripe_account = None
+ elif tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ stripe_account = tenant.stripe_connect_id
+ else:
+ return Response({
+ 'payouts': [],
+ 'has_more': False,
+ })
+
+ try:
+ params = {'limit': limit}
+ if stripe_account:
+ params['stripe_account'] = stripe_account
+
+ payouts = stripe.Payout.list(**params)
+
+ results = []
+ for payout in payouts.data:
+ results.append({
+ 'id': payout.id,
+ 'amount': payout.amount,
+ 'amount_display': f'${payout.amount / 100:.2f}',
+ 'currency': payout.currency,
+ 'status': payout.status,
+ 'arrival_date': payout.arrival_date,
+ 'created': payout.created,
+ 'description': payout.description,
+ 'destination': payout.destination,
+ 'failure_message': payout.failure_message,
+ 'method': payout.method,
+ 'type': payout.type,
+ })
+
+ return Response({
+ 'payouts': results,
+ 'has_more': payouts.has_more,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+ finally:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class StripeBalanceView(APIView):
+ """
+ Get current Stripe balance.
+
+ GET /payments/transactions/balance/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ """Get balance from Stripe API."""
+ tenant = request.tenant
+
+ # Determine which Stripe key to use
+ if tenant.payment_mode == 'direct_api' and tenant.stripe_secret_key:
+ stripe.api_key = tenant.stripe_secret_key
+ stripe_account = None
+ elif tenant.payment_mode == 'connect' and tenant.stripe_connect_id:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+ stripe_account = tenant.stripe_connect_id
+ else:
+ return Response({
+ 'available': [],
+ 'pending': [],
+ 'available_total': 0,
+ 'pending_total': 0,
+ })
+
+ try:
+ params = {}
+ if stripe_account:
+ params['stripe_account'] = stripe_account
+
+ balance = stripe.Balance.retrieve(**params)
+
+ available = []
+ pending = []
+ available_total = 0
+ pending_total = 0
+
+ for item in balance.available:
+ available.append({
+ 'amount': item.amount,
+ 'currency': item.currency,
+ 'amount_display': f'${item.amount / 100:.2f}',
+ })
+ if item.currency == 'usd':
+ available_total = item.amount
+
+ for item in balance.pending:
+ pending.append({
+ 'amount': item.amount,
+ 'currency': item.currency,
+ 'amount_display': f'${item.amount / 100:.2f}',
+ })
+ if item.currency == 'usd':
+ pending_total = item.amount
+
+ return Response({
+ 'available': available,
+ 'pending': pending,
+ 'available_total': available_total,
+ 'pending_total': pending_total,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+ finally:
+ stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class TransactionExportView(APIView):
+ """
+ Export transaction data.
+
+ POST /payments/transactions/export/
+ """
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ """Export transactions to various formats."""
+ # This is a placeholder - full implementation would generate CSV/PDF/etc
+ format_type = request.data.get('format', 'csv')
+
+ return Response(
+ {'error': 'Export functionality not yet implemented'},
+ status=status.HTTP_501_NOT_IMPLEMENTED
+ )
+
+
+# ============================================================================
+# Payment Intent Endpoints (existing)
+# ============================================================================
+
class CreatePaymentIntentView(APIView):
"""
Create a Stripe PaymentIntent for an event.
-
- POST /api/payments/payment-intents/
-
- Request Body:
- {
- "event_id": 123,
- "payment_method_id": "pm_card_visa" (optional),
- "locale": "es" (optional, default: "en")
- }
-
- Response:
- {
- "client_secret": "pi_xxx_secret_yyy",
- "payment_intent_id": "pi_xxx",
- "amount": "100.00",
- "currency": "usd",
- "application_fee_amount": "5.00"
- }
-
- The client_secret is used on the frontend to confirm payment.
+
+ POST /payments/payment-intents/
"""
permission_classes = [IsAuthenticated]
-
+
def post(self, request):
"""Create payment intent for an event"""
- # Extract request data
event_id = request.data.get('event_id')
payment_method_id = request.data.get('payment_method_id')
locale = request.data.get('locale', 'en')
- amount = request.data.get('amount') # Allow explicit amount or calculate from event
-
- # Validate event_id
+ amount = request.data.get('amount')
+
if not event_id:
return Response(
{'error': 'event_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Get the event
+
try:
event = Event.objects.get(id=event_id)
except Event.DoesNotExist:
@@ -61,8 +978,7 @@ class CreatePaymentIntentView(APIView):
{'error': f'Event {event_id} not found'},
status=status.HTTP_404_NOT_FOUND
)
-
- # Validate amount
+
if amount:
try:
amount = Decimal(str(amount))
@@ -72,13 +988,11 @@ class CreatePaymentIntentView(APIView):
status=status.HTTP_400_BAD_REQUEST
)
else:
- # FUTURE: Calculate amount from event pricing
return Response(
- {'error': 'amount is required (future: auto-calculate from event)'},
+ {'error': 'amount is required'},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Get Stripe service for current tenant
+
try:
stripe_svc = get_stripe_service_for_tenant(request.tenant)
except ValueError as e:
@@ -86,8 +1000,7 @@ class CreatePaymentIntentView(APIView):
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Create payment intent
+
try:
payment_intent, transaction = stripe_svc.create_payment_intent(
event=event,
@@ -95,7 +1008,7 @@ class CreatePaymentIntentView(APIView):
payment_method_id=payment_method_id,
locale=locale
)
-
+
return Response({
'client_secret': payment_intent.client_secret,
'payment_intent_id': payment_intent.id,
@@ -104,9 +1017,8 @@ class CreatePaymentIntentView(APIView):
'application_fee_amount': str(transaction.application_fee_amount),
'status': transaction.status,
}, status=status.HTTP_201_CREATED)
-
+
except Exception as e:
- # Log error in production
return Response(
{'error': f'Payment intent creation failed: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
@@ -115,22 +1027,14 @@ class CreatePaymentIntentView(APIView):
class TerminalConnectionTokenView(APIView):
"""
- Get a Stripe Terminal connection token for in-person payments.
-
- POST /api/payments/terminal/connection-token/
-
- Response:
- {
- "secret": "pst_test_xxx"
- }
-
- Used by Stripe Terminal SDK to connect card readers.
+ Get a Stripe Terminal connection token.
+
+ POST /payments/terminal/connection-token/
"""
permission_classes = [IsAuthenticated]
-
+
def post(self, request):
"""Get terminal connection token"""
- # Get Stripe service for current tenant
try:
stripe_svc = get_stripe_service_for_tenant(request.tenant)
except ValueError as e:
@@ -138,15 +1042,10 @@ class TerminalConnectionTokenView(APIView):
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Get connection token
+
try:
token = stripe_svc.get_terminal_token()
-
- return Response({
- 'secret': token.secret
- }, status=status.HTTP_200_OK)
-
+ return Response({'secret': token.secret})
except Exception as e:
return Response(
{'error': f'Terminal token creation failed: {str(e)}'},
@@ -156,32 +1055,24 @@ class TerminalConnectionTokenView(APIView):
class RefundPaymentView(APIView):
"""
- Refund a payment (full or partial).
-
- POST /api/payments/refunds/
-
- Request Body:
- {
- "payment_intent_id": "pi_xxx",
- "amount": "50.00" (optional, full refund if not specified),
- "reason": "requested_by_customer" (optional)
- }
+ Refund a payment.
+
+ POST /payments/refunds/
"""
permission_classes = [IsAuthenticated]
-
+
def post(self, request):
"""Create refund"""
payment_intent_id = request.data.get('payment_intent_id')
amount = request.data.get('amount')
reason = request.data.get('reason', 'requested_by_customer')
-
+
if not payment_intent_id:
return Response(
{'error': 'payment_intent_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Convert amount if provided
+
if amount:
try:
amount = Decimal(str(amount))
@@ -190,8 +1081,7 @@ class RefundPaymentView(APIView):
{'error': 'Invalid amount format'},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Get Stripe service
+
try:
stripe_svc = get_stripe_service_for_tenant(request.tenant)
except ValueError as e:
@@ -199,22 +1089,21 @@ class RefundPaymentView(APIView):
{'error': str(e)},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Create refund
+
try:
refund = stripe_svc.refund_payment(
payment_intent_id=payment_intent_id,
amount=amount,
reason=reason
)
-
+
return Response({
'refund_id': refund.id,
- 'amount': refund.amount / 100, # Convert from cents
+ 'amount': refund.amount / 100,
'status': refund.status,
'reason': refund.reason,
}, status=status.HTTP_201_CREATED)
-
+
except Exception as e:
return Response(
{'error': f'Refund failed: {str(e)}'},
diff --git a/smoothschedule/platform_admin/management/__init__.py b/smoothschedule/platform_admin/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/platform_admin/management/commands/__init__.py b/smoothschedule/platform_admin/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py b/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py
new file mode 100644
index 0000000..3cc50db
--- /dev/null
+++ b/smoothschedule/platform_admin/management/commands/seed_subscription_plans.py
@@ -0,0 +1,429 @@
+"""
+Management command to seed default subscription plans.
+
+Usage:
+ python manage.py seed_subscription_plans
+ python manage.py seed_subscription_plans --force # Override existing plans
+"""
+
+from django.core.management.base import BaseCommand
+from platform_admin.models import SubscriptionPlan
+
+
+class Command(BaseCommand):
+ help = 'Seeds default subscription plans for the platform'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--force',
+ action='store_true',
+ help='Override existing plans with the same name',
+ )
+
+ def handle(self, *args, **options):
+ force = options['force']
+
+ plans = [
+ # Free Tier
+ {
+ 'name': 'Free',
+ 'description': 'Perfect for getting started. Try out the core features with no commitment.',
+ 'plan_type': 'base',
+ 'business_tier': 'Free',
+ 'price_monthly': None,
+ 'price_yearly': None,
+ 'features': [
+ 'Up to 2 team members',
+ 'Up to 5 resources',
+ '50 appointments per month',
+ 'Basic scheduling',
+ 'Email notifications',
+ 'Mobile-friendly booking page',
+ 'Community support',
+ ],
+ 'limits': {
+ 'max_users': 2,
+ 'max_resources': 5,
+ 'max_appointments': 50,
+ 'max_services': 10,
+ 'max_automated_tasks': 0,
+ 'max_email_templates': 3,
+ },
+ 'permissions': {
+ 'can_accept_payments': False,
+ 'sms_reminders': False,
+ 'advanced_reporting': False,
+ 'priority_support': False,
+ 'can_use_custom_domain': False,
+ 'can_create_plugins': False,
+ 'can_white_label': False,
+ 'can_api_access': False,
+ 'can_use_masked_phone_numbers': False,
+ 'can_use_email_templates': True,
+ 'can_customize_booking_page': False,
+ 'can_export_data': False,
+ },
+ 'transaction_fee_percent': 0,
+ 'transaction_fee_fixed': 0,
+ 'sms_enabled': False,
+ 'masked_calling_enabled': False,
+ 'proxy_number_enabled': False,
+ 'is_active': True,
+ 'is_public': True,
+ 'is_most_popular': False,
+ 'show_price': True,
+ },
+ # Starter Tier
+ {
+ 'name': 'Starter',
+ 'description': 'Great for small businesses ready to grow. Essential tools to manage your appointments.',
+ 'plan_type': 'base',
+ 'business_tier': 'Starter',
+ 'price_monthly': 19.00,
+ 'price_yearly': 190.00,
+ 'features': [
+ 'Up to 5 team members',
+ 'Up to 15 resources',
+ 'Unlimited appointments',
+ 'Online payments (2.9% + $0.30)',
+ 'SMS reminders',
+ 'Custom booking page colors',
+ 'Google Calendar sync',
+ 'Basic analytics',
+ 'Email support',
+ ],
+ 'limits': {
+ 'max_users': 5,
+ 'max_resources': 15,
+ 'max_appointments': -1, # Unlimited
+ 'max_services': 25,
+ 'max_automated_tasks': 3,
+ 'max_email_templates': 10,
+ },
+ 'permissions': {
+ 'can_accept_payments': True,
+ 'sms_reminders': True,
+ 'advanced_reporting': False,
+ 'priority_support': False,
+ 'can_use_custom_domain': False,
+ 'can_create_plugins': False,
+ 'can_white_label': False,
+ 'can_api_access': False,
+ 'can_use_masked_phone_numbers': False,
+ 'can_use_email_templates': True,
+ 'can_customize_booking_page': True,
+ 'can_export_data': True,
+ },
+ 'transaction_fee_percent': 2.9,
+ 'transaction_fee_fixed': 0.30,
+ 'sms_enabled': True,
+ 'sms_price_per_message_cents': 3,
+ 'masked_calling_enabled': False,
+ 'proxy_number_enabled': False,
+ 'is_active': True,
+ 'is_public': True,
+ 'is_most_popular': False,
+ 'show_price': True,
+ },
+ # Professional Tier
+ {
+ 'name': 'Professional',
+ 'description': 'For growing teams that need powerful automation and customization.',
+ 'plan_type': 'base',
+ 'business_tier': 'Professional',
+ 'price_monthly': 49.00,
+ 'price_yearly': 490.00,
+ 'features': [
+ 'Up to 15 team members',
+ 'Unlimited resources',
+ 'Unlimited appointments',
+ 'Lower payment fees (2.5% + $0.25)',
+ 'SMS & masked calling',
+ 'Custom domain',
+ 'Advanced analytics',
+ 'Automated workflows',
+ 'Custom email templates',
+ 'API access',
+ 'Priority email support',
+ ],
+ 'limits': {
+ 'max_users': 15,
+ 'max_resources': -1, # Unlimited
+ 'max_appointments': -1,
+ 'max_services': -1,
+ 'max_automated_tasks': 10,
+ 'max_email_templates': -1,
+ },
+ 'permissions': {
+ 'can_accept_payments': True,
+ 'sms_reminders': True,
+ 'advanced_reporting': True,
+ 'priority_support': True,
+ 'can_use_custom_domain': True,
+ 'can_create_plugins': False,
+ 'can_white_label': False,
+ 'can_api_access': True,
+ 'can_use_masked_phone_numbers': True,
+ 'can_use_email_templates': True,
+ 'can_customize_booking_page': True,
+ 'can_export_data': True,
+ },
+ 'transaction_fee_percent': 2.5,
+ 'transaction_fee_fixed': 0.25,
+ 'sms_enabled': True,
+ 'sms_price_per_message_cents': 3,
+ 'masked_calling_enabled': True,
+ 'masked_calling_price_per_minute_cents': 5,
+ 'proxy_number_enabled': True,
+ 'proxy_number_monthly_fee_cents': 200,
+ 'default_auto_reload_enabled': True,
+ 'default_auto_reload_threshold_cents': 1000,
+ 'default_auto_reload_amount_cents': 2500,
+ 'is_active': True,
+ 'is_public': True,
+ 'is_most_popular': True,
+ 'show_price': True,
+ },
+ # Business Tier
+ {
+ 'name': 'Business',
+ 'description': 'For established businesses with multiple locations or large teams.',
+ 'plan_type': 'base',
+ 'business_tier': 'Business',
+ 'price_monthly': 99.00,
+ 'price_yearly': 990.00,
+ 'features': [
+ 'Up to 50 team members',
+ 'Unlimited resources',
+ 'Unlimited appointments',
+ 'Lowest payment fees (2.2% + $0.20)',
+ 'All communication features',
+ 'Multiple custom domains',
+ 'White-label option',
+ 'Custom plugins',
+ 'Advanced automation',
+ 'Dedicated onboarding',
+ 'Priority phone support',
+ ],
+ 'limits': {
+ 'max_users': 50,
+ 'max_resources': -1,
+ 'max_appointments': -1,
+ 'max_services': -1,
+ 'max_automated_tasks': 25,
+ 'max_email_templates': -1,
+ },
+ 'permissions': {
+ 'can_accept_payments': True,
+ 'sms_reminders': True,
+ 'advanced_reporting': True,
+ 'priority_support': True,
+ 'can_use_custom_domain': True,
+ 'can_create_plugins': True,
+ 'can_white_label': True,
+ 'can_api_access': True,
+ 'can_use_masked_phone_numbers': True,
+ 'can_use_email_templates': True,
+ 'can_customize_booking_page': True,
+ 'can_export_data': True,
+ },
+ 'transaction_fee_percent': 2.2,
+ 'transaction_fee_fixed': 0.20,
+ 'sms_enabled': True,
+ 'sms_price_per_message_cents': 2,
+ 'masked_calling_enabled': True,
+ 'masked_calling_price_per_minute_cents': 4,
+ 'proxy_number_enabled': True,
+ 'proxy_number_monthly_fee_cents': 150,
+ 'default_auto_reload_enabled': True,
+ 'default_auto_reload_threshold_cents': 2000,
+ 'default_auto_reload_amount_cents': 5000,
+ 'is_active': True,
+ 'is_public': True,
+ 'is_most_popular': False,
+ 'show_price': True,
+ },
+ # Enterprise Tier
+ {
+ 'name': 'Enterprise',
+ 'description': 'Custom solutions for large organizations with complex needs.',
+ 'plan_type': 'base',
+ 'business_tier': 'Enterprise',
+ 'price_monthly': None, # Contact us
+ 'price_yearly': None,
+ 'features': [
+ 'Unlimited team members',
+ 'Unlimited everything',
+ 'Custom payment terms',
+ 'Dedicated infrastructure',
+ 'Custom integrations',
+ 'SSO/SAML support',
+ 'SLA guarantee',
+ 'Dedicated account manager',
+ '24/7 priority support',
+ 'Custom contracts',
+ ],
+ 'limits': {
+ 'max_users': -1,
+ 'max_resources': -1,
+ 'max_appointments': -1,
+ 'max_services': -1,
+ 'max_automated_tasks': -1,
+ 'max_email_templates': -1,
+ },
+ 'permissions': {
+ 'can_accept_payments': True,
+ 'sms_reminders': True,
+ 'advanced_reporting': True,
+ 'priority_support': True,
+ 'can_use_custom_domain': True,
+ 'can_create_plugins': True,
+ 'can_white_label': True,
+ 'can_api_access': True,
+ 'can_use_masked_phone_numbers': True,
+ 'can_use_email_templates': True,
+ 'can_customize_booking_page': True,
+ 'can_export_data': True,
+ 'sso_enabled': True,
+ 'dedicated_support': True,
+ },
+ 'transaction_fee_percent': 2.0,
+ 'transaction_fee_fixed': 0.15,
+ 'sms_enabled': True,
+ 'sms_price_per_message_cents': 2,
+ 'masked_calling_enabled': True,
+ 'masked_calling_price_per_minute_cents': 3,
+ 'proxy_number_enabled': True,
+ 'proxy_number_monthly_fee_cents': 100,
+ 'default_auto_reload_enabled': True,
+ 'default_auto_reload_threshold_cents': 5000,
+ 'default_auto_reload_amount_cents': 10000,
+ 'is_active': True,
+ 'is_public': True,
+ 'is_most_popular': False,
+ 'show_price': False, # Contact us
+ },
+ # Add-ons
+ {
+ 'name': 'Extra Team Members',
+ 'description': 'Add more team members to your plan.',
+ 'plan_type': 'addon',
+ 'business_tier': '',
+ 'price_monthly': 5.00,
+ 'price_yearly': 50.00,
+ 'features': [
+ 'Add 1 additional team member',
+ 'Full feature access for new member',
+ ],
+ 'limits': {
+ 'additional_users': 1,
+ },
+ 'permissions': {},
+ 'transaction_fee_percent': 0,
+ 'transaction_fee_fixed': 0,
+ 'is_active': True,
+ 'is_public': True,
+ 'show_price': True,
+ },
+ {
+ 'name': 'SMS Bundle',
+ 'description': 'Bulk SMS credits at a discounted rate.',
+ 'plan_type': 'addon',
+ 'business_tier': '',
+ 'price_monthly': 20.00,
+ 'price_yearly': None,
+ 'features': [
+ '1,000 SMS credits',
+ 'Never expires',
+ '33% savings vs pay-as-you-go',
+ ],
+ 'limits': {
+ 'sms_credits': 1000,
+ },
+ 'permissions': {},
+ 'transaction_fee_percent': 0,
+ 'transaction_fee_fixed': 0,
+ 'is_active': True,
+ 'is_public': True,
+ 'show_price': True,
+ },
+ {
+ 'name': 'Additional Proxy Number',
+ 'description': 'Add a dedicated phone number for masked calling.',
+ 'plan_type': 'addon',
+ 'business_tier': '',
+ 'price_monthly': 2.00,
+ 'price_yearly': 20.00,
+ 'features': [
+ '1 dedicated phone number',
+ 'US or Canadian number',
+ 'Masked calling support',
+ ],
+ 'limits': {
+ 'additional_proxy_numbers': 1,
+ },
+ 'permissions': {},
+ 'transaction_fee_percent': 0,
+ 'transaction_fee_fixed': 0,
+ 'is_active': True,
+ 'is_public': True,
+ 'show_price': True,
+ },
+ {
+ 'name': 'White Label',
+ 'description': 'Remove all SmoothSchedule branding from your booking pages.',
+ 'plan_type': 'addon',
+ 'business_tier': '',
+ 'price_monthly': 29.00,
+ 'price_yearly': 290.00,
+ 'features': [
+ 'Remove SmoothSchedule branding',
+ 'Custom footer text',
+ 'Custom email sender name',
+ ],
+ 'limits': {},
+ 'permissions': {
+ 'can_white_label': True,
+ },
+ 'transaction_fee_percent': 0,
+ 'transaction_fee_fixed': 0,
+ 'is_active': True,
+ 'is_public': True,
+ 'show_price': True,
+ },
+ ]
+
+ created_count = 0
+ updated_count = 0
+ skipped_count = 0
+
+ for plan_data in plans:
+ existing = SubscriptionPlan.objects.filter(name=plan_data['name']).first()
+
+ if existing:
+ if force:
+ for key, value in plan_data.items():
+ setattr(existing, key, value)
+ existing.save()
+ updated_count += 1
+ self.stdout.write(
+ self.style.WARNING(f"Updated: {plan_data['name']}")
+ )
+ else:
+ skipped_count += 1
+ self.stdout.write(
+ self.style.NOTICE(f"Skipped (exists): {plan_data['name']}")
+ )
+ else:
+ SubscriptionPlan.objects.create(**plan_data)
+ created_count += 1
+ self.stdout.write(
+ self.style.SUCCESS(f"Created: {plan_data['name']}")
+ )
+
+ self.stdout.write('')
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Done! Created: {created_count}, Updated: {updated_count}, Skipped: {skipped_count}"
+ )
+ )
diff --git a/smoothschedule/platform_admin/migrations/0010_subscriptionplan_default_auto_reload_amount_cents_and_more.py b/smoothschedule/platform_admin/migrations/0010_subscriptionplan_default_auto_reload_amount_cents_and_more.py
new file mode 100644
index 0000000..76cadf7
--- /dev/null
+++ b/smoothschedule/platform_admin/migrations/0010_subscriptionplan_default_auto_reload_amount_cents_and_more.py
@@ -0,0 +1,58 @@
+# Generated by Django 5.2.8 on 2025-12-02 02:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0009_add_email_check_interval'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='default_auto_reload_amount_cents',
+ field=models.IntegerField(default=2500, help_text='Default auto-reload amount in cents'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='default_auto_reload_enabled',
+ field=models.BooleanField(default=False, help_text='Whether auto-reload is enabled by default for new tenants'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='default_auto_reload_threshold_cents',
+ field=models.IntegerField(default=1000, help_text='Default auto-reload threshold in cents'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='masked_calling_enabled',
+ field=models.BooleanField(default=False, help_text='Whether masked calling is available for this tier'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='masked_calling_price_per_minute_cents',
+ field=models.IntegerField(default=5, help_text='Price per voice minute in cents'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='proxy_number_enabled',
+ field=models.BooleanField(default=False, help_text='Whether tenants can have dedicated proxy numbers'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='proxy_number_monthly_fee_cents',
+ field=models.IntegerField(default=200, help_text='Monthly fee per proxy number in cents'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='sms_enabled',
+ field=models.BooleanField(default=False, help_text='Whether SMS reminders are available for this tier'),
+ ),
+ migrations.AddField(
+ model_name='subscriptionplan',
+ name='sms_price_per_message_cents',
+ field=models.IntegerField(default=3, help_text='Price per SMS message in cents'),
+ ),
+ ]
diff --git a/smoothschedule/platform_admin/models.py b/smoothschedule/platform_admin/models.py
index 9acafd4..17e3d6a 100644
--- a/smoothschedule/platform_admin/models.py
+++ b/smoothschedule/platform_admin/models.py
@@ -252,6 +252,50 @@ class SubscriptionPlan(models.Model):
help_text="Fixed transaction fee in dollars"
)
+ # SMS & Communication Settings
+ sms_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether SMS reminders are available for this tier"
+ )
+ sms_price_per_message_cents = models.IntegerField(
+ default=3, # $0.03
+ help_text="Price per SMS message in cents"
+ )
+
+ # Masked Calling Settings
+ masked_calling_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether masked calling is available for this tier"
+ )
+ masked_calling_price_per_minute_cents = models.IntegerField(
+ default=5, # $0.05
+ help_text="Price per voice minute in cents"
+ )
+
+ # Proxy Phone Number Settings
+ proxy_number_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether tenants can have dedicated proxy numbers"
+ )
+ proxy_number_monthly_fee_cents = models.IntegerField(
+ default=200, # $2.00
+ help_text="Monthly fee per proxy number in cents"
+ )
+
+ # Default Credit Settings (for new tenants on this tier)
+ default_auto_reload_enabled = models.BooleanField(
+ default=False,
+ help_text="Whether auto-reload is enabled by default for new tenants"
+ )
+ default_auto_reload_threshold_cents = models.IntegerField(
+ default=1000, # $10
+ help_text="Default auto-reload threshold in cents"
+ )
+ default_auto_reload_amount_cents = models.IntegerField(
+ default=2500, # $25
+ help_text="Default auto-reload amount in cents"
+ )
+
# Visibility
is_active = models.BooleanField(default=True)
is_public = models.BooleanField(
diff --git a/smoothschedule/schedule/email_template_presets.py b/smoothschedule/schedule/email_template_presets.py
new file mode 100644
index 0000000..01d0dbd
--- /dev/null
+++ b/smoothschedule/schedule/email_template_presets.py
@@ -0,0 +1,1106 @@
+"""
+Email Template Presets for SmoothSchedule
+
+This module contains pre-built email templates with multiple style variations
+for each category. Users can select a template and customize it to their needs.
+
+These templates are designed to be visually appealing and work across major
+email clients with inline CSS styling.
+"""
+
+# Template presets organized by category with multiple style variations
+EMAIL_TEMPLATE_PRESETS = {
+ "CONFIRMATION": [
+ {
+ "name": "Modern Blue",
+ "description": "Clean, modern confirmation with blue hero section and card-based details",
+ "style": "professional",
+ "subject": "Your Appointment is Confirmed - {{APPOINTMENT_DATE}}",
+ "html_content": """
+
+
+
+
+ Appointment Confirmed - Modern
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You're All Set, {{CUSTOMER_NAME}}!
+
+
+ We are excited to see you at {{BUSINESS_NAME}} . Your appointment has been confirmed for the following time:
+
+
+
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+
+
+
+
+ Date
+ {{APPOINTMENT_DATE}}
+
+
+ Time
+ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+ {{BUSINESS_PHONE}} • {{BUSINESS_EMAIL}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """You're All Set, {{CUSTOMER_NAME}}!
+
+We are excited to see you at {{BUSINESS_NAME}}. Your appointment has been confirmed.
+
+APPOINTMENT DETAILS
+-------------------
+Service: {{APPOINTMENT_SERVICE}}
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+
+We look forward to seeing you!
+
+{{BUSINESS_NAME}}
+{{BUSINESS_PHONE}} • {{BUSINESS_EMAIL}}"""
+ },
+ {
+ "name": "Classic Serif",
+ "description": "Elegant, timeless design with serif typography and clean lines",
+ "style": "professional",
+ "subject": "Appointment Confirmation - {{BUSINESS_NAME}}",
+ "html_content": """
+
+
+
+
+ Appointment Confirmed - Classic
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Appointment Confirmation
+
+
+ Dear {{CUSTOMER_NAME}},
+ We are pleased to confirm your appointment with {{BUSINESS_NAME}}. Please review the details below.
+
+
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+
+
+ Date
+ {{APPOINTMENT_DATE}}
+
+
+
+
+
+ Time
+ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+ {{BUSINESS_PHONE}} | {{BUSINESS_EMAIL}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """Appointment Confirmation
+
+Dear {{CUSTOMER_NAME}},
+
+We are pleased to confirm your appointment with {{BUSINESS_NAME}}. Please review the details below.
+
+DETAILS
+-------
+Service: {{APPOINTMENT_SERVICE}}
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+
+{{BUSINESS_NAME}}
+{{BUSINESS_PHONE}} | {{BUSINESS_EMAIL}}"""
+ },
+ {
+ "name": "Bold Dark",
+ "description": "Bold, dark theme with high contrast and modern grid layout",
+ "style": "professional",
+ "subject": "Confirmed: Your Appointment on {{APPOINTMENT_DATE}}",
+ "html_content": """
+
+
+
+
+ Appointment Confirmed - Bold
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ready for you, {{CUSTOMER_NAME}}.
+
+
+ Your slot is locked in. We've got everything prepared for your upcoming visit.
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+ When
+ {{APPOINTMENT_DATE}}{{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+
+
+ Scan at front desk to check in
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} • {{BUSINESS_PHONE}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """CONFIRMED
+
+Ready for you, {{CUSTOMER_NAME}}.
+
+Your slot is locked in. We've got everything prepared for your upcoming visit.
+
+DETAILS
+-------
+Service: {{APPOINTMENT_SERVICE}}
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+
+{{BUSINESS_NAME}} • {{BUSINESS_PHONE}}"""
+ }
+ ],
+
+ "REMINDER": [
+ {
+ "name": "Soft & Clean",
+ "description": "Gentle, soft pink design with centered layout and friendly tone",
+ "style": "friendly",
+ "subject": "Reminder: Your Appointment is Coming Up!",
+ "html_content": """
+
+
+
+
+ Reminder - Soft & Clean
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Just a Friendly Reminder
+
+
+ Hi {{CUSTOMER_NAME}}, your appointment with {{BUSINESS_NAME}} is coming up soon!
+
+
+
+
{{APPOINTMENT_DATE}}
+
{{APPOINTMENT_TIME}}
+
+
+
+ Need to make changes? Reschedule here
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """Just a Friendly Reminder
+
+Hi {{CUSTOMER_NAME}}, your appointment with {{BUSINESS_NAME}} is coming up soon!
+
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+
+Need to make changes? Contact us to reschedule.
+
+{{BUSINESS_NAME}}"""
+ },
+ {
+ "name": "Urgent Bold",
+ "description": "High-impact urgent reminder with bold typography and red accent",
+ "style": "professional",
+ "subject": "Action Required: Don't Forget Your Appointment Tomorrow!",
+ "html_content": """
+
+
+
+
+ Reminder - Urgent
+
+
+
+
+
+
+
+
+ Action Required
+
+ Don't Forget Your Visit.
+
+
+
+
+
+ {{CUSTOMER_NAME}} , we're holding your spot for {{APPOINTMENT_SERVICE}} .
+
+
+
+
+
+ {{APPOINTMENT_DATE}} @ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} - {{BUSINESS_PHONE}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """ACTION REQUIRED
+
+Don't Forget Your Visit.
+
+{{CUSTOMER_NAME}}, we're holding your spot for {{APPOINTMENT_SERVICE}}.
+
+{{APPOINTMENT_DATE}} @ {{APPOINTMENT_TIME}}
+
+{{BUSINESS_NAME}} - {{BUSINESS_PHONE}}"""
+ },
+ {
+ "name": "Personal Note",
+ "description": "Elegant letter-style reminder that feels like a personal note",
+ "style": "friendly",
+ "subject": "Looking Forward to Seeing You Tomorrow",
+ "html_content": """
+
+
+
+
+ Reminder - Personal Note
+
+
+
+
+
+
+
+
+
+ Dear {{CUSTOMER_NAME}},
+
+
+ I'm writing to confirm that we're still on for your {{APPOINTMENT_SERVICE}} tomorrow, {{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}} .
+
+
+ Looking forward to our session.
+
+
+ Warmly,
+ {{BUSINESS_NAME}}
+
+
+
+
+
+ Reschedule or Cancel
+
+
+
+
+
+""",
+ "text_content": """Dear {{CUSTOMER_NAME}},
+
+I'm writing to confirm that we're still on for your {{APPOINTMENT_SERVICE}} tomorrow, {{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}}.
+
+Looking forward to our session.
+
+Warmly,
+{{BUSINESS_NAME}}"""
+ }
+ ],
+
+ "MARKETING": [
+ {
+ "name": "Welcome Vibrant",
+ "description": "Vibrant welcome email with gradient accents and feature highlights",
+ "style": "friendly",
+ "subject": "Welcome to {{BUSINESS_NAME}}! 🎉",
+ "html_content": """
+
+
+
+
+ Marketing - Vibrant
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome to the family.
+
+
+ Thanks for joining {{BUSINESS_NAME}} ! We're thrilled to have you on board.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Expert Staff
+ Top-tier professionals ready to serve.
+
+
+ Easy Booking
+ Schedule anytime, anywhere.
+
+
+ Best Value
+ Premium service at great rates.
+
+
+
+
+
+
+
+
+
+ Book Your First Visit
+
+
+
+
+
+
+
+""",
+ "text_content": """Welcome to the family!
+
+Thanks for joining {{BUSINESS_NAME}}! We're thrilled to have you on board.
+
+WHY CHOOSE US:
+• Expert Staff - Top-tier professionals ready to serve
+• Easy Booking - Schedule anytime, anywhere
+• Best Value - Premium service at great rates
+
+Book Your First Visit today!
+
+{{BUSINESS_NAME}}"""
+ },
+ {
+ "name": "Minimalist Promo",
+ "description": "Bold minimalist promotional email with coupon code focus",
+ "style": "minimalist",
+ "subject": "Flash Sale: Limited Time Only!",
+ "html_content": """
+
+
+
+
+ Marketing - Minimalist Promo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ limited time only
+
+
+ Treat yourself to something special. For the next 48 hours, get exclusive access to our VIP booking slots and a special discount.
+
+
+
+
+
+
+
+
+ Claim Offer
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """FLASH SALE
+LIMITED TIME ONLY
+
+Treat yourself to something special. For the next 48 hours, get exclusive access to our VIP booking slots and a special discount.
+
+USE CODE: VIP20
+
+Claim your offer now!
+
+{{BUSINESS_NAME}}"""
+ },
+ {
+ "name": "Newsletter Grid",
+ "description": "Modern newsletter layout with featured content and grid sections",
+ "style": "professional",
+ "subject": "{{BUSINESS_NAME}} Monthly Update",
+ "html_content": """
+
+
+
+
+ Marketing - Newsletter Grid
+
+
+
+
+
+
+ View this email in your browser
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} MONTHLY
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Introducing Our New Premium Service
+
+ We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
+
+ Read more →
+
+
+
+
+
+
+
+
+
+
+
Employee of the Month
+
+ Meet Sarah, our lead specialist who has gone above and beyond this month.
+
+
+
+
+
+
+
+
Community Events
+
+ Join us this weekend for our local charity drive.
+
+
+
+
+
+
+
+
+
+
+
+ © {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
+
+
+ {{BUSINESS_ADDRESS}}
+
+
+
+
+
+
+
+
+""",
+ "text_content": """{{BUSINESS_NAME}} MONTHLY
+
+INTRODUCING OUR NEW PREMIUM SERVICE
+We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
+
+EMPLOYEE OF THE MONTH
+Meet Sarah, our lead specialist who has gone above and beyond this month.
+
+COMMUNITY EVENTS
+Join us this weekend for our local charity drive.
+
+© {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
+{{BUSINESS_ADDRESS}}"""
+ }
+ ],
+
+ "REPORT": [
+ {
+ "name": "Monthly Data Report",
+ "description": "Clean data-focused monthly report with charts and metrics table",
+ "style": "professional",
+ "subject": "Monthly Performance Report - {{BUSINESS_NAME}}",
+ "html_content": """
+
+
+
+
+ Monthly Report - Data Heavy
+
+
+
+
+
+
+
+
+
+
+ Smooth Schedule Report
+
+
+
+ {{TODAY}}
+
+
+
+
+
+
+
+
+ Performance Summary
+
+
+
+
+
+
+
+ Metric
+ Value
+ Change
+
+
+ Total Revenue
+ $12,450
+ +12% ▲
+
+
+ Appointments
+ 142
+ +5% ▲
+
+
+ New Customers
+ 28
+ -2% ▼
+
+
+
+
+ This report was automatically generated for {{BUSINESS_NAME}} .
+
+
+
+
+
+ View Full Report in Dashboard →
+
+
+
+
+
+
+
+""",
+ "text_content": """SMOOTHSCHEDULE REPORT
+{{TODAY}}
+
+PERFORMANCE SUMMARY
+-------------------
+Total Revenue: $12,450 (+12%)
+Appointments: 142 (+5%)
+New Customers: 28 (-2%)
+
+This report was automatically generated for {{BUSINESS_NAME}}.
+
+View Full Report in Dashboard"""
+ },
+ {
+ "name": "Weekly Snapshot",
+ "description": "Dark theme weekly report with card-based stats layout",
+ "style": "professional",
+ "subject": "Weekly Snapshot - {{BUSINESS_NAME}}",
+ "html_content": """
+
+
+
+
+ Report - Weekly Snapshot
+
+
+
+
+
+
+
+
+
+ Weekly Snapshot
+ Week of {{TODAY}}
+
+
+
+
+
+
+
+
+
+
+
+ Revenue
+ $4,250
+ ↑ 15% vs last week
+
+
+
+
+
+ Bookings
+ 84
+ → Stable
+
+
+
+
+
+
+
+ Avg Rating
+ 4.9
+
+
+
+
+
+ Cancellations
+ 3
+
+
+
+
+
+
+
+
+
+ View detailed analytics
+
+
+
+
+
+
+
+""",
+ "text_content": """WEEKLY SNAPSHOT
+Week of {{TODAY}}
+
+REVENUE: $4,250 (↑ 15% vs last week)
+BOOKINGS: 84 (Stable)
+AVG RATING: 4.9
+CANCELLATIONS: 3
+
+View detailed analytics in your dashboard."""
+ },
+ {
+ "name": "Staff Leaderboard",
+ "description": "Staff performance leaderboard with rankings and metrics",
+ "style": "friendly",
+ "subject": "Staff Performance Report - {{BUSINESS_NAME}}",
+ "html_content": """
+
+
+
+
+ Report - Staff Leaderboard
+
+
+
+
+
+
+
+
+
+ Staff Performance
+ Top performers for {{TODAY}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sarah Johnson
+ 32 Appointments
+
+
+ $3,200
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mike Chen
+ 28 Appointments
+
+
+ $2,850
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Jessica Williams
+ 25 Appointments
+
+
+ $2,100
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Great work team! 🚀
+
+
+
+
+
+
+
+
+""",
+ "text_content": """STAFF PERFORMANCE
+Top performers for {{TODAY}}
+
+1. Sarah Johnson - 32 Appointments - $3,200
+2. Mike Chen - 28 Appointments - $2,850
+3. Jessica Williams - 25 Appointments - $2,100
+
+Great work team! 🚀"""
+ }
+ ],
+
+ "APPOINTMENT": [],
+ "NOTIFICATION": [],
+ "OTHER": []
+}
+
+
+def get_presets_by_category(category):
+ """Get all template presets for a specific category"""
+ return EMAIL_TEMPLATE_PRESETS.get(category, [])
+
+
+def get_all_presets():
+ """Get all template presets organized by category"""
+ return EMAIL_TEMPLATE_PRESETS
+
+
+def get_preset_by_name(name):
+ """Find a preset by its name across all categories"""
+ for category, presets in EMAIL_TEMPLATE_PRESETS.items():
+ for preset in presets:
+ if preset['name'] == name:
+ return {**preset, 'category': category}
+ return None
diff --git a/smoothschedule/schedule/management/commands/seed_email_templates.py b/smoothschedule/schedule/management/commands/seed_email_templates.py
new file mode 100644
index 0000000..75a8c6f
--- /dev/null
+++ b/smoothschedule/schedule/management/commands/seed_email_templates.py
@@ -0,0 +1,1829 @@
+"""
+Management command to seed default email templates.
+
+These templates are created for new businesses and can be customized.
+Platform templates are shared across all tenants.
+
+Usage:
+ # Seed templates for all schemas
+ python manage.py seed_email_templates
+
+ # Seed templates for a specific schema
+ python manage.py seed_email_templates --schema=demo
+
+ # Force reset to defaults (overwrites existing)
+ python manage.py seed_email_templates --reset
+"""
+from django.core.management.base import BaseCommand
+from django.db import connection
+from django_tenants.utils import schema_context, get_tenant_model
+
+
+class Command(BaseCommand):
+ help = 'Seed default email templates for tenants'
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ '--schema',
+ type=str,
+ help='Specific tenant schema to seed (default: all tenants)',
+ )
+ parser.add_argument(
+ '--reset',
+ action='store_true',
+ help='Reset templates to defaults (overwrites existing)',
+ )
+
+ def handle(self, *args, **options):
+ schema = options.get('schema')
+ reset = options.get('reset', False)
+
+ if schema:
+ # Seed specific schema
+ self.seed_schema(schema, reset)
+ else:
+ # Seed all tenant schemas
+ Tenant = get_tenant_model()
+ tenants = Tenant.objects.exclude(schema_name='public')
+
+ for tenant in tenants:
+ self.seed_schema(tenant.schema_name, reset)
+
+ self.stdout.write(self.style.SUCCESS('Email templates seeded successfully!'))
+
+ def seed_schema(self, schema_name, reset=False):
+ """Seed templates for a specific schema"""
+ self.stdout.write(f'Seeding templates for schema: {schema_name}')
+
+ with schema_context(schema_name):
+ from schedule.models import EmailTemplate
+
+ if reset:
+ # Delete all existing templates first
+ deleted_count = EmailTemplate.objects.all().delete()[0]
+ self.stdout.write(f' Deleted {deleted_count} existing templates')
+
+ templates = self.get_default_templates()
+
+ for template_data in templates:
+ name = template_data['name']
+
+ if reset:
+ # Already deleted above, just create
+ EmailTemplate.objects.create(**template_data)
+ self.stdout.write(f' Created: {name}')
+ else:
+ # Only create if doesn't exist
+ _, created = EmailTemplate.objects.get_or_create(
+ name=name,
+ defaults=template_data
+ )
+ if created:
+ self.stdout.write(f' Created: {name}')
+ else:
+ self.stdout.write(f' Skipped (exists): {name}')
+
+ def get_default_templates(self):
+ """Return list of default email templates"""
+ return [
+ # ========== CONFIRMATION TEMPLATES ==========
+ {
+ 'name': 'Appointment Confirmation - Modern Blue',
+ 'description': 'Clean, corporate style with rounded corners and blue hero header. Ideal for medical, tech, or professional services.',
+ 'category': 'CONFIRMATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Your appointment at {{BUSINESS_NAME}} is confirmed!',
+ 'html_content': self.get_confirmation_modern_blue(),
+ 'text_content': self.get_confirmation_text(),
+ },
+ {
+ 'name': 'Appointment Confirmation - Classic Serif',
+ 'description': 'Elegant style with serif fonts, warm beige background. Perfect for law firms, salons, or luxury brands.',
+ 'category': 'CONFIRMATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Appointment Confirmation - {{BUSINESS_NAME}}',
+ 'html_content': self.get_confirmation_classic_serif(),
+ 'text_content': self.get_confirmation_text(),
+ },
+ {
+ 'name': 'Appointment Confirmation - Bold Dark',
+ 'description': 'High contrast dark mode with vibrant pink accents. Great for gyms, barbershops, or nightlife venues.',
+ 'category': 'CONFIRMATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Confirmed! Your appointment at {{BUSINESS_NAME}}',
+ 'html_content': self.get_confirmation_bold_dark(),
+ 'text_content': self.get_confirmation_text(),
+ },
+
+ # ========== REMINDER TEMPLATES ==========
+ {
+ 'name': 'Appointment Reminder - Soft Clean',
+ 'description': 'Minimalist style with soft pink/rose colors. Friendly and non-intrusive.',
+ 'category': 'REMINDER',
+ 'scope': 'BUSINESS',
+ 'subject': 'Reminder: Your appointment at {{BUSINESS_NAME}} is coming up!',
+ 'html_content': self.get_reminder_soft_clean(),
+ 'text_content': self.get_reminder_text(),
+ },
+ {
+ 'name': 'Appointment Reminder - Urgent Bold',
+ 'description': 'Bold red accents with "Action Required" styling to reduce no-shows.',
+ 'category': 'REMINDER',
+ 'scope': 'BUSINESS',
+ 'subject': 'ACTION REQUIRED: Your appointment at {{BUSINESS_NAME}}',
+ 'html_content': self.get_reminder_urgent_bold(),
+ 'text_content': self.get_reminder_text(),
+ },
+
+ # ========== MARKETING TEMPLATES ==========
+ {
+ 'name': 'Welcome - Vibrant',
+ 'description': 'High energy welcome email with gradient top bar and image collages. Designed to excite new customers.',
+ 'category': 'MARKETING',
+ 'scope': 'BUSINESS',
+ 'subject': 'Welcome to {{BUSINESS_NAME}}!',
+ 'html_content': self.get_marketing_welcome_vibrant(),
+ 'text_content': self.get_welcome_text(),
+ },
+ {
+ 'name': 'Promo - Minimalist',
+ 'description': 'Black and white monospace design with bold typography. Perfect for flash sales and limited offers.',
+ 'category': 'MARKETING',
+ 'scope': 'BUSINESS',
+ 'subject': 'Limited Time Offer from {{BUSINESS_NAME}}',
+ 'html_content': self.get_marketing_minimalist_promo(),
+ 'text_content': self.get_promo_text(),
+ },
+ {
+ 'name': 'Newsletter - Grid Layout',
+ 'description': 'Magazine-style newsletter with featured content, staff spotlight, and community events.',
+ 'category': 'MARKETING',
+ 'scope': 'BUSINESS',
+ 'subject': '{{BUSINESS_NAME}} Monthly Newsletter',
+ 'html_content': self.get_marketing_newsletter_grid(),
+ 'text_content': self.get_newsletter_text(),
+ },
+
+ # ========== REPORT TEMPLATES ==========
+ {
+ 'name': 'Report - Monthly Data',
+ 'description': 'Utility-focused layout with data grid and performance metrics. Designed for clarity and readability.',
+ 'category': 'REPORT',
+ 'scope': 'BUSINESS',
+ 'subject': 'Monthly Performance Report - {{BUSINESS_NAME}}',
+ 'html_content': self.get_report_monthly_data(),
+ 'text_content': self.get_monthly_report_text(),
+ },
+ {
+ 'name': 'Report - Weekly Snapshot',
+ 'description': 'Dark themed card layout showing key weekly metrics: revenue, bookings, ratings, and cancellations.',
+ 'category': 'REPORT',
+ 'scope': 'BUSINESS',
+ 'subject': 'Weekly Snapshot - {{BUSINESS_NAME}}',
+ 'html_content': self.get_report_weekly_cards(),
+ 'text_content': self.get_weekly_report_text(),
+ },
+ {
+ 'name': 'Report - Staff Leaderboard',
+ 'description': 'Staff performance rankings with appointment counts and revenue. Great for team motivation.',
+ 'category': 'REPORT',
+ 'scope': 'BUSINESS',
+ 'subject': 'Staff Performance Report - {{BUSINESS_NAME}}',
+ 'html_content': self.get_report_staff_leaderboard(),
+ 'text_content': self.get_staff_report_text(),
+ },
+
+ # ========== NOTIFICATION TEMPLATES ==========
+ {
+ 'name': 'Appointment Rescheduled',
+ 'description': 'Sent when an appointment is rescheduled',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Your appointment at {{BUSINESS_NAME}} has been rescheduled',
+ 'html_content': self.get_appointment_rescheduled_html(),
+ 'text_content': self.get_appointment_rescheduled_text(),
+ },
+ {
+ 'name': 'Appointment Cancelled',
+ 'description': 'Sent when an appointment is cancelled',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Your appointment at {{BUSINESS_NAME}} has been cancelled',
+ 'html_content': self.get_appointment_cancelled_html(),
+ 'text_content': self.get_appointment_cancelled_text(),
+ },
+ {
+ 'name': 'Thank You - Appointment Complete',
+ 'description': 'Sent after an appointment is completed',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': 'Thank you for visiting {{BUSINESS_NAME}}!',
+ 'html_content': self.get_thank_you_html(),
+ 'text_content': self.get_thank_you_text(),
+ },
+
+ # ========== TICKET NOTIFICATIONS ==========
+ {
+ 'name': 'Ticket Assigned',
+ 'description': 'Notification when a ticket is assigned to a staff member',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': '[Ticket #{{TICKET_ID}}] You have been assigned: {{TICKET_SUBJECT}}',
+ 'html_content': self.get_ticket_assigned_html(),
+ 'text_content': self.get_ticket_assigned_text(),
+ },
+ {
+ 'name': 'Ticket Status Changed',
+ 'description': 'Notification when ticket status changes',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': '[Ticket #{{TICKET_ID}}] Status updated: {{TICKET_STATUS}}',
+ 'html_content': self.get_ticket_status_changed_html(),
+ 'text_content': self.get_ticket_status_changed_text(),
+ },
+ {
+ 'name': 'Ticket Reply - Staff Notification',
+ 'description': 'Notification to staff when customer replies to ticket',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': '[Ticket #{{TICKET_ID}}] New reply from customer: {{TICKET_SUBJECT}}',
+ 'html_content': self.get_ticket_reply_staff_html(),
+ 'text_content': self.get_ticket_reply_staff_text(),
+ },
+ {
+ 'name': 'Ticket Reply - Customer Notification',
+ 'description': 'Notification to customer when staff replies to ticket',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': '[Ticket #{{TICKET_ID}}] {{BUSINESS_NAME}} has responded to your request',
+ 'html_content': self.get_ticket_reply_customer_html(),
+ 'text_content': self.get_ticket_reply_customer_text(),
+ },
+ {
+ 'name': 'Ticket Resolved',
+ 'description': 'Notification when a ticket is resolved/closed',
+ 'category': 'NOTIFICATION',
+ 'scope': 'BUSINESS',
+ 'subject': '[Ticket #{{TICKET_ID}}] Your request has been resolved',
+ 'html_content': self.get_ticket_resolved_html(),
+ 'text_content': self.get_ticket_resolved_text(),
+ },
+ ]
+
+ # ========== CONFIRMATION TEMPLATES ==========
+
+ def get_confirmation_modern_blue(self):
+ return '''
+
+
+
+
+ Appointment Confirmed
+
+
+
+
+
+
+
+
+
+ Appointment Confirmed
+
+
+
+
+
+
+
+ You're All Set, {{CUSTOMER_NAME}}!
+
+
+ We are excited to see you at {{BUSINESS_NAME}} . Your appointment has been confirmed for the following time:
+
+
+
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+
+
+
+
+ Date
+ {{APPOINTMENT_DATE}}
+
+
+ Time
+ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+ {{BUSINESS_PHONE}} • {{BUSINESS_EMAIL}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_confirmation_classic_serif(self):
+ return '''
+
+
+
+
+ Appointment Confirmed
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+
+
+
+
+
+ Appointment Confirmation
+
+
+ Dear {{CUSTOMER_NAME}},
+ We are pleased to confirm your appointment with {{BUSINESS_NAME}}. Please review the details below.
+
+
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+
+
+ Date
+ {{APPOINTMENT_DATE}}
+
+
+
+
+
+ Time
+ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+ {{BUSINESS_PHONE}} | {{BUSINESS_EMAIL}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_confirmation_bold_dark(self):
+ return '''
+
+
+
+
+ Appointment Confirmed
+
+
+
+
+
+
+
+
+
+ CONFIRMED
+
+
+
+
+
+
+
+ Ready for you, {{CUSTOMER_NAME}}.
+
+
+ Your slot is locked in. We've got everything prepared for your upcoming visit.
+
+
+
+
+
+
+ Service
+ {{APPOINTMENT_SERVICE}}
+
+
+
+ When
+ {{APPOINTMENT_DATE}}{{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} • {{BUSINESS_PHONE}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_confirmation_text(self):
+ return '''Your Appointment is Confirmed!
+
+Hi {{CUSTOMER_NAME}},
+
+Great news! Your appointment at {{BUSINESS_NAME}} has been confirmed.
+
+APPOINTMENT DETAILS
+-------------------
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+Service: {{APPOINTMENT_SERVICE}}
+
+Need to make changes?
+If you need to reschedule or cancel, please contact us as soon as possible.
+
+See you soon!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ # ========== REMINDER TEMPLATES ==========
+
+ def get_reminder_soft_clean(self):
+ return '''
+
+
+
+
+ Appointment Reminder
+
+
+
+
+
+
+
+
+
+
+ ⏰
+
+
+
+
+
+
+
+
+ Just a Friendly Reminder
+
+
+ Hi {{CUSTOMER_NAME}}, your appointment with {{BUSINESS_NAME}} is coming up soon!
+
+
+
+
{{APPOINTMENT_DATE}}
+
{{APPOINTMENT_TIME}}
+
+
+
+ Need to make changes? Reschedule here
+
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_reminder_urgent_bold(self):
+ return '''
+
+
+
+
+ Appointment Reminder
+
+
+
+
+
+
+
+
+ Action Required
+
+ Don't Forget Your Visit.
+
+
+
+ {{CUSTOMER_NAME}} , we're holding your spot for {{APPOINTMENT_SERVICE}} .
+
+
+
+
+
+ {{APPOINTMENT_DATE}} @ {{APPOINTMENT_TIME}}
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} - {{BUSINESS_PHONE}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_reminder_text(self):
+ return '''Reminder: Your Appointment is Coming Up!
+
+Hi {{CUSTOMER_NAME}},
+
+This is a friendly reminder that your appointment at {{BUSINESS_NAME}} is coming up soon.
+
+APPOINTMENT DETAILS
+-------------------
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+Service: {{APPOINTMENT_SERVICE}}
+
+We recommend arriving 5-10 minutes early to ensure a smooth check-in.
+
+Need to reschedule?
+Please contact us as soon as possible if you need to make any changes.
+
+See you soon!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ # ========== MARKETING TEMPLATES ==========
+
+ def get_marketing_welcome_vibrant(self):
+ return '''
+
+
+
+
+ Welcome
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ S
+
+
+
+
+
+
+
+
+ Welcome to the family.
+
+
+ Thanks for joining {{BUSINESS_NAME}} ! We're thrilled to have you on board.
+
+
+
+
+
+
+
+
+
+
+ Expert Staff
+ Top-tier professionals ready to serve.
+
+
+ Easy Booking
+ Schedule anytime, anywhere.
+
+
+ Best Value
+ Premium service at great rates.
+
+
+
+
+
+
+
+
+
+ Book Your First Visit
+
+
+
+
+
+
+
+'''
+
+ def get_marketing_minimalist_promo(self):
+ return '''
+
+
+
+
+ Special Offer
+
+
+
+
+
+
+
+
+
+ FLASH SALE
+
+
+
+
+
+
+
+ limited time only
+
+
+ Treat yourself to something special. For the next 48 hours, get exclusive access to our VIP booking slots and a special discount.
+
+
+
+
+
+
+
+
+ Claim Offer
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_marketing_newsletter_grid(self):
+ return '''
+
+
+
+
+ Newsletter
+
+
+
+
+
+
+ View this email in your browser
+
+
+
+
+
+
+
+
+
+ {{BUSINESS_NAME}} MONTHLY
+
+
+
+
+
+
+
+
+ New Service Launch
+
+
+
+
+ Introducing Our New Premium Service
+
+ We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
+
+ Read more →
+
+
+
+
+
+
+
+
+
+ ★
+
+
+
Employee of the Month
+
+ Meet our lead specialist who has gone above and beyond this month.
+
+
+
+
+
+
+ ♥
+
+
+
Community Events
+
+ Join us this weekend for our local charity drive.
+
+
+
+
+
+
+
+
+
+
+
+ © {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
+
+
+ {{BUSINESS_ADDRESS}}
+
+
+
+
+
+
+
+
+'''
+
+ def get_welcome_text(self):
+ return '''Welcome to {{BUSINESS_NAME}}!
+
+Hi {{CUSTOMER_NAME}},
+
+Welcome! We're thrilled to have you join our community at {{BUSINESS_NAME}}.
+
+Your account is all set up!
+
+Here's what you can do:
+- Book appointments online anytime
+- View and manage your upcoming appointments
+- Update your contact information and preferences
+
+Ready to book your first appointment?
+We can't wait to see you!
+
+Best regards,
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ def get_promo_text(self):
+ return '''LIMITED TIME OFFER from {{BUSINESS_NAME}}
+
+FLASH SALE - 48 HOURS ONLY
+
+Treat yourself to something special!
+
+Get exclusive access to our VIP booking slots and a special discount.
+
+USE CODE: VIP20
+
+Visit us online or call to claim this offer.
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ def get_newsletter_text(self):
+ return '''{{BUSINESS_NAME}} MONTHLY NEWSLETTER
+
+INTRODUCING OUR NEW PREMIUM SERVICE
+
+We've been listening to your feedback and are excited to announce a brand new way to experience {{BUSINESS_NAME}}. Our new premium tier offers extended hours and dedicated support.
+
+EMPLOYEE OF THE MONTH
+Meet our lead specialist who has gone above and beyond this month.
+
+COMMUNITY EVENTS
+Join us this weekend for our local charity drive.
+
+---
+© {{TODAY}} {{BUSINESS_NAME}}. All rights reserved.
+{{BUSINESS_ADDRESS}}
+'''
+
+ # ========== REPORT TEMPLATES ==========
+
+ def get_report_monthly_data(self):
+ return '''
+
+
+
+
+ Monthly Report
+
+
+
+
+
+
+
+
+
+
+ Smooth Schedule Report
+
+
+
+ {{TODAY}}
+
+
+
+
+
+
+
+
+ Performance Summary
+
+
+
+
+
+ Revenue
+ {{TOTAL_REVENUE}}
+
+
+ Appointments
+ {{TOTAL_APPOINTMENTS}}
+
+
+ New Customers
+ {{NEW_CUSTOMERS}}
+
+
+
+
+
+ This report was automatically generated for {{BUSINESS_NAME}} .
+
+
+
+
+
+ View Full Report in Dashboard →
+
+
+
+
+
+
+
+'''
+
+ def get_report_weekly_cards(self):
+ return '''
+
+
+
+
+ Weekly Snapshot
+
+
+
+
+
+
+
+
+
+ Weekly Snapshot
+ Week of {{TODAY}}
+
+
+
+
+
+
+
+
+
+
+
+ $
+
+ Revenue
+ {{WEEKLY_REVENUE}}
+ {{REVENUE_CHANGE}}
+
+
+
+
+
+ #
+
+ Bookings
+ {{WEEKLY_BOOKINGS}}
+ {{BOOKINGS_CHANGE}}
+
+
+
+
+
+
+
+ ★
+
+ Avg Rating
+ {{AVG_RATING}}
+
+
+
+
+
+ !
+
+ Cancellations
+ {{CANCELLATIONS}}
+
+
+
+
+
+
+
+
+
+ View detailed analytics
+
+
+
+
+
+
+
+'''
+
+ def get_report_staff_leaderboard(self):
+ return '''
+
+
+
+
+ Staff Performance
+
+
+
+
+
+
+
+
+
+ Staff Performance
+ Top performers for {{TODAY}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+ {{TOP_STAFF_1_NAME}}
+ {{TOP_STAFF_1_APPOINTMENTS}} Appointments
+
+
+ {{TOP_STAFF_1_REVENUE}}
+
+
+
+
+
+
+
+
+
+
+
+
+ 2
+
+
+
+ {{TOP_STAFF_2_NAME}}
+ {{TOP_STAFF_2_APPOINTMENTS}} Appointments
+
+
+ {{TOP_STAFF_2_REVENUE}}
+
+
+
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
+ {{TOP_STAFF_3_NAME}}
+ {{TOP_STAFF_3_APPOINTMENTS}} Appointments
+
+
+ {{TOP_STAFF_3_REVENUE}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Great work team!
+
+
+
+
+
+
+
+
+'''
+
+ def get_monthly_report_text(self):
+ return '''MONTHLY PERFORMANCE REPORT - {{BUSINESS_NAME}}
+
+Date: {{TODAY}}
+
+PERFORMANCE SUMMARY
+-------------------
+Total Revenue: {{TOTAL_REVENUE}}
+Appointments: {{TOTAL_APPOINTMENTS}}
+New Customers: {{NEW_CUSTOMERS}}
+
+This report was automatically generated for {{BUSINESS_NAME}}.
+
+View full report in your dashboard.
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ def get_weekly_report_text(self):
+ return '''WEEKLY SNAPSHOT - {{BUSINESS_NAME}}
+
+Week of {{TODAY}}
+
+KEY METRICS
+-----------
+Revenue: {{WEEKLY_REVENUE}} ({{REVENUE_CHANGE}})
+Bookings: {{WEEKLY_BOOKINGS}} ({{BOOKINGS_CHANGE}})
+Avg Rating: {{AVG_RATING}}
+Cancellations: {{CANCELLATIONS}}
+
+View detailed analytics in your dashboard.
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ def get_staff_report_text(self):
+ return '''STAFF PERFORMANCE REPORT - {{BUSINESS_NAME}}
+
+Top Performers for {{TODAY}}
+
+1. {{TOP_STAFF_1_NAME}}
+ Appointments: {{TOP_STAFF_1_APPOINTMENTS}}
+ Revenue: {{TOP_STAFF_1_REVENUE}}
+
+2. {{TOP_STAFF_2_NAME}}
+ Appointments: {{TOP_STAFF_2_APPOINTMENTS}}
+ Revenue: {{TOP_STAFF_2_REVENUE}}
+
+3. {{TOP_STAFF_3_NAME}}
+ Appointments: {{TOP_STAFF_3_APPOINTMENTS}}
+ Revenue: {{TOP_STAFF_3_REVENUE}}
+
+Great work team!
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ # ========== NOTIFICATION TEMPLATES ==========
+
+ def get_email_wrapper_start(self, title=''):
+ return f'''
+
+
+
+
+ {title}
+
+
+
+
+
+'''
+
+ def get_email_wrapper_end(self):
+ return '''
+
+
+
+
+'''
+
+ def get_appointment_rescheduled_html(self):
+ return self.get_email_wrapper_start('Appointment Rescheduled') + '''
+ Your Appointment Has Been Rescheduled
+
+ Hi {{CUSTOMER_NAME}},
+
+
+ Your appointment at {{BUSINESS_NAME}} has been rescheduled.
+ Please note the new date and time below.
+
+
+
+
+ 📅 New Date:
+ {{APPOINTMENT_DATE}}
+
+
+ 🕐 New Time:
+ {{APPOINTMENT_TIME}}
+
+
+ 💼 Service:
+ {{APPOINTMENT_SERVICE}}
+
+
+
+
+ If this new time doesn't work for you, please contact us to find an alternative.
+
+
+ Thank you for your understanding!
+ The {{BUSINESS_NAME}} Team
+''' + self.get_email_wrapper_end()
+
+ def get_appointment_rescheduled_text(self):
+ return '''Your Appointment Has Been Rescheduled
+
+Hi {{CUSTOMER_NAME}},
+
+Your appointment at {{BUSINESS_NAME}} has been rescheduled.
+
+NEW APPOINTMENT DETAILS
+-----------------------
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+Service: {{APPOINTMENT_SERVICE}}
+
+If this new time doesn't work for you, please contact us to find an alternative.
+
+Thank you for your understanding!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ def get_appointment_cancelled_html(self):
+ return self.get_email_wrapper_start('Appointment Cancelled') + '''
+ Your Appointment Has Been Cancelled
+
+ Hi {{CUSTOMER_NAME}},
+
+
+ We're writing to confirm that your appointment at {{BUSINESS_NAME}}
+ has been cancelled.
+
+
+
+
+ Cancelled Appointment:
+ {{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}}
+ Service: {{APPOINTMENT_SERVICE}}
+
+
+
+
+ We'd love to see you! Would you like to book a new appointment?
+ Visit our booking page or give us a call.
+
+
+ Thank you!
+ The {{BUSINESS_NAME}} Team
+''' + self.get_email_wrapper_end()
+
+ def get_appointment_cancelled_text(self):
+ return '''Your Appointment Has Been Cancelled
+
+Hi {{CUSTOMER_NAME}},
+
+We're writing to confirm that your appointment at {{BUSINESS_NAME}} has been cancelled.
+
+CANCELLED APPOINTMENT
+---------------------
+Date: {{APPOINTMENT_DATE}}
+Time: {{APPOINTMENT_TIME}}
+Service: {{APPOINTMENT_SERVICE}}
+
+We'd love to see you! Would you like to book a new appointment?
+Visit our booking page or give us a call.
+
+Thank you!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ def get_thank_you_html(self):
+ return self.get_email_wrapper_start('Thank You') + '''
+ Thank You for Visiting!
+
+ Hi {{CUSTOMER_NAME}},
+
+
+ Thank you for choosing {{BUSINESS_NAME}} !
+ We hope you had a wonderful experience with us.
+
+
+
+
+ We'd Love Your Feedback!
+
+
+ Your opinion helps us improve and helps others find great services.
+
+
+
+
+ Ready to book your next appointment? We're here whenever you need us!
+
+
+ See you again soon!
+ The {{BUSINESS_NAME}} Team
+''' + self.get_email_wrapper_end()
+
+ def get_thank_you_text(self):
+ return '''Thank You for Visiting!
+
+Hi {{CUSTOMER_NAME}},
+
+Thank you for choosing {{BUSINESS_NAME}}! We hope you had a wonderful experience with us.
+
+We'd Love Your Feedback!
+Your opinion helps us improve and helps others find great services.
+
+Ready to book your next appointment? We're here whenever you need us!
+
+See you again soon!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ # ========== TICKET NOTIFICATION TEMPLATES ==========
+
+ def get_ticket_assigned_html(self):
+ return self.get_email_wrapper_start('Ticket Assigned') + '''
+ New Ticket Assigned to You
+
+ Hi {{ASSIGNEE_NAME}},
+
+
+ A ticket has been assigned to you and requires your attention.
+
+
+
+
+ 🎫 Ticket:
+ #{{TICKET_ID}}
+
+
+ 📋 Subject:
+ {{TICKET_SUBJECT}}
+
+
+ ⚡ Priority:
+ {{TICKET_PRIORITY}}
+
+
+ 👤 From:
+ {{TICKET_CUSTOMER_NAME}}
+
+
+
+
+
Message:
+
{{TICKET_MESSAGE}}
+
+
+
+ View Ticket
+
+
+ Please respond as soon as possible.
+ {{BUSINESS_NAME}}
+''' + self.get_email_wrapper_end()
+
+ def get_ticket_assigned_text(self):
+ return '''New Ticket Assigned to You
+
+Hi {{ASSIGNEE_NAME}},
+
+A ticket has been assigned to you and requires your attention.
+
+TICKET DETAILS
+--------------
+Ticket: #{{TICKET_ID}}
+Subject: {{TICKET_SUBJECT}}
+Priority: {{TICKET_PRIORITY}}
+From: {{TICKET_CUSTOMER_NAME}}
+
+Message:
+{{TICKET_MESSAGE}}
+
+View ticket: {{TICKET_URL}}
+
+Please respond as soon as possible.
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ def get_ticket_status_changed_html(self):
+ return self.get_email_wrapper_start('Ticket Status Updated') + '''
+ Ticket Status Updated
+
+ Hi {{RECIPIENT_NAME}},
+
+
+ The status of ticket #{{TICKET_ID}} has been updated.
+
+
+
+
+ 🎫 Ticket:
+ #{{TICKET_ID}}
+
+
+ 📋 Subject:
+ {{TICKET_SUBJECT}}
+
+
+ 📊 New Status:
+ {{TICKET_STATUS}}
+
+
+
+
+ View Ticket
+
+
+ {{BUSINESS_NAME}}
+''' + self.get_email_wrapper_end()
+
+ def get_ticket_status_changed_text(self):
+ return '''Ticket Status Updated
+
+Hi {{RECIPIENT_NAME}},
+
+The status of ticket #{{TICKET_ID}} has been updated.
+
+TICKET DETAILS
+--------------
+Ticket: #{{TICKET_ID}}
+Subject: {{TICKET_SUBJECT}}
+New Status: {{TICKET_STATUS}}
+
+View ticket: {{TICKET_URL}}
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ def get_ticket_reply_staff_html(self):
+ return self.get_email_wrapper_start('New Customer Reply') + '''
+ New Reply on Ticket #{{TICKET_ID}}
+
+ Hi {{ASSIGNEE_NAME}},
+
+
+ {{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}} .
+
+
+
+
+ Subject: {{TICKET_SUBJECT}}
+
+
{{REPLY_MESSAGE}}
+
+
+
+ View & Reply
+
+
+ {{BUSINESS_NAME}}
+''' + self.get_email_wrapper_end()
+
+ def get_ticket_reply_staff_text(self):
+ return '''New Reply on Ticket #{{TICKET_ID}}
+
+Hi {{ASSIGNEE_NAME}},
+
+{{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}}.
+
+Subject: {{TICKET_SUBJECT}}
+
+Reply:
+{{REPLY_MESSAGE}}
+
+View & reply: {{TICKET_URL}}
+
+---
+{{BUSINESS_NAME}}
+'''
+
+ def get_ticket_reply_customer_html(self):
+ return self.get_email_wrapper_start('Response to Your Request') + '''
+ We've Responded to Your Request
+
+ Hi {{CUSTOMER_NAME}},
+
+
+ We've replied to your support request.
+
+
+
+
+ 🎫 Ticket:
+ #{{TICKET_ID}}
+
+
+ 📋 Subject:
+ {{TICKET_SUBJECT}}
+
+
+
+
+
Our Response:
+
{{REPLY_MESSAGE}}
+
+
+
+ Need to reply?
+ Simply reply to this email or click the button below.
+
+
+
+ View Full Conversation
+
+
+ Thank you for contacting us!
+ The {{BUSINESS_NAME}} Team
+''' + self.get_email_wrapper_end()
+
+ def get_ticket_reply_customer_text(self):
+ return '''We've Responded to Your Request
+
+Hi {{CUSTOMER_NAME}},
+
+We've replied to your support request.
+
+TICKET DETAILS
+--------------
+Ticket: #{{TICKET_ID}}
+Subject: {{TICKET_SUBJECT}}
+
+Our Response:
+{{REPLY_MESSAGE}}
+
+Need to reply?
+Simply reply to this email or visit: {{TICKET_URL}}
+
+Thank you for contacting us!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
+
+ def get_ticket_resolved_html(self):
+ return self.get_email_wrapper_start('Ticket Resolved') + '''
+ Your Request Has Been Resolved
+
+ Hi {{CUSTOMER_NAME}},
+
+
+ Great news! Your support request has been resolved.
+
+
+
+
+ ✅ Ticket #{{TICKET_ID}} - Resolved
+
+
+
+
+
+ Subject: {{TICKET_SUBJECT}}
+
+
+ Resolution: {{RESOLUTION_MESSAGE}}
+
+
+
+
+ Not satisfied with the resolution?
+ You can reopen this ticket by replying to this email within the next 7 days.
+
+
+
+ View Ticket History
+
+
+ Thank you for your patience!
+ The {{BUSINESS_NAME}} Team
+''' + self.get_email_wrapper_end()
+
+ def get_ticket_resolved_text(self):
+ return '''Your Request Has Been Resolved
+
+Hi {{CUSTOMER_NAME}},
+
+Great news! Your support request has been resolved.
+
+Ticket #{{TICKET_ID}} - RESOLVED
+
+Subject: {{TICKET_SUBJECT}}
+Resolution: {{RESOLUTION_MESSAGE}}
+
+Not satisfied with the resolution?
+You can reopen this ticket by replying to this email within the next 7 days.
+
+View ticket history: {{TICKET_URL}}
+
+Thank you for your patience!
+The {{BUSINESS_NAME}} Team
+
+---
+{{BUSINESS_NAME}}
+{{BUSINESS_EMAIL}}
+{{BUSINESS_PHONE}}
+'''
diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py
index bba5331..91ba09c 100644
--- a/smoothschedule/schedule/serializers.py
+++ b/smoothschedule/schedule/serializers.py
@@ -219,10 +219,34 @@ class EventSerializer(serializers.ModelSerializer):
Serializer for Event model with availability validation.
CRITICAL: Validates resource availability before saving via AvailabilityService.
+
+ Status mapping (frontend -> backend):
+ - PENDING -> SCHEDULED
+ - CONFIRMED -> SCHEDULED
+ - CANCELLED -> CANCELED
+ - NO_SHOW -> NOSHOW
"""
+ # Status mapping: frontend value -> backend value
+ STATUS_MAPPING = {
+ 'PENDING': 'SCHEDULED',
+ 'CONFIRMED': 'SCHEDULED',
+ 'CANCELLED': 'CANCELED',
+ 'NO_SHOW': 'NOSHOW',
+ }
+
+ # Reverse mapping for serialization: backend value -> frontend value
+ STATUS_REVERSE_MAPPING = {
+ 'SCHEDULED': 'CONFIRMED',
+ 'CANCELED': 'CANCELLED',
+ 'NOSHOW': 'NO_SHOW',
+ }
+
participants = ParticipantSerializer(many=True, read_only=True)
duration_minutes = serializers.SerializerMethodField()
+ # Override status field to allow frontend values
+ status = serializers.CharField(required=False)
+
# Simplified fields for frontend compatibility
resource_id = serializers.SerializerMethodField()
customer_id = serializers.SerializerMethodField()
@@ -292,7 +316,27 @@ class EventSerializer(serializers.ModelSerializer):
def get_is_paid(self, obj):
"""Check if event is paid"""
return obj.status == 'PAID'
-
+
+ def validate_status(self, value):
+ """Map frontend status values to backend values"""
+ if value in self.STATUS_MAPPING:
+ return self.STATUS_MAPPING[value]
+ # Accept backend values directly
+ valid_backend_statuses = [s.value for s in Event.Status]
+ if value in valid_backend_statuses:
+ return value
+ raise serializers.ValidationError(
+ f'Invalid status "{value}". Valid values are: '
+ f'{", ".join(list(self.STATUS_MAPPING.keys()) + valid_backend_statuses)}'
+ )
+
+ def to_representation(self, instance):
+ """Map backend status values to frontend values when serializing"""
+ data = super().to_representation(instance)
+ if 'status' in data and data['status'] in self.STATUS_REVERSE_MAPPING:
+ data['status'] = self.STATUS_REVERSE_MAPPING[data['status']]
+ return data
+
def validate(self, attrs):
"""
Validate event timing and resource availability.
diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py
index 9f09ef6..34ccf07 100644
--- a/smoothschedule/schedule/views.py
+++ b/smoothschedule/schedule/views.py
@@ -258,11 +258,14 @@ class ServiceViewSet(viewsets.ModelViewSet):
API endpoint for managing Services.
Services are the offerings a business provides (e.g., Haircut, Massage).
+
+ Permissions:
+ - Subject to MAX_SERVICES quota (hard block on creation)
"""
queryset = Service.objects.filter(is_active=True)
serializer_class = ServiceSerializer
# TODO: Re-enable authentication for production
- permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
+ permission_classes = [AllowAny, HasQuota('MAX_SERVICES')] # Temporarily allow unauthenticated access for development
filterset_fields = ['is_active']
search_fields = ['name', 'description']
@@ -1416,9 +1419,14 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
{
'category': 'Appointment',
'items': [
- {'code': '{{APPOINTMENT_TIME}}', 'description': 'Full date and time'},
- {'code': '{{APPOINTMENT_DATE}}', 'description': 'Date only'},
- {'code': '{{APPOINTMENT_SERVICE}}', 'description': 'Service name'},
+ {'code': '{{EVENT_START_DATETIME}}', 'description': 'Full date and time'},
+ {'code': '{{EVENT_START_DATE}}', 'description': 'Date only'},
+ {'code': '{{EVENT_START_TIME}}', 'description': 'Time only'},
+ {'code': '{{EVENT_ID}}', 'description': 'Event/Appointment ID'},
+ {'code': '{{SERVICE_NAME}}', 'description': 'Service name'},
+ {'code': '{{SERVICE_DURATION}}', 'description': 'Service duration'},
+ {'code': '{{SERVICE_PRICE}}', 'description': 'Service price'},
+ {'code': '{{STAFF_NAME}}', 'description': 'Staff member name'},
]
},
{
@@ -1430,4 +1438,51 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
},
],
'categories': [choice[0] for choice in EmailTemplate.Category.choices],
- })
\ No newline at end of file
+ })
+
+ @action(detail=False, methods=['get'])
+ def presets(self, request):
+ """
+ Get pre-built email template presets organized by category.
+
+ Users can select a preset and customize it to create their own template.
+ Each category has multiple style variations (professional, friendly, minimalist).
+
+ Query params:
+ - category: Filter presets by category (APPOINTMENT, REMINDER, etc.)
+
+ Returns:
+ {
+ "presets": {
+ "APPOINTMENT": [
+ {
+ "name": "Appointment Confirmation - Professional",
+ "description": "Clean, professional...",
+ "style": "professional",
+ "subject": "...",
+ "html_content": "...",
+ "text_content": "..."
+ },
+ ...
+ ],
+ ...
+ }
+ }
+ """
+ from .email_template_presets import get_presets_by_category, get_all_presets
+
+ category = request.query_params.get('category')
+
+ if category:
+ # Return presets for specific category
+ category_upper = category.upper()
+ presets = get_presets_by_category(category_upper)
+ return Response({
+ 'category': category_upper,
+ 'presets': presets
+ })
+ else:
+ # Return all presets organized by category
+ return Response({
+ 'presets': get_all_presets()
+ })
\ No newline at end of file
diff --git a/smoothschedule/smoothschedule/comms_credits/__init__.py b/smoothschedule/smoothschedule/comms_credits/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/smoothschedule/comms_credits/admin.py b/smoothschedule/smoothschedule/comms_credits/admin.py
new file mode 100644
index 0000000..387fe3b
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/admin.py
@@ -0,0 +1,83 @@
+from django.contrib import admin
+from .models import CommunicationCredits, CreditTransaction, ProxyPhoneNumber, MaskedSession
+
+
+@admin.register(CommunicationCredits)
+class CommunicationCreditsAdmin(admin.ModelAdmin):
+ list_display = [
+ 'tenant',
+ 'balance_display',
+ 'auto_reload_enabled',
+ 'auto_reload_threshold_display',
+ 'last_twilio_sync_at',
+ ]
+ list_filter = ['auto_reload_enabled']
+ search_fields = ['tenant__name']
+ readonly_fields = [
+ 'total_loaded_cents', 'total_spent_cents',
+ 'last_twilio_sync_at', 'created_at', 'updated_at'
+ ]
+
+ def balance_display(self, obj):
+ return f"${obj.balance_cents/100:.2f}"
+ balance_display.short_description = 'Balance'
+
+ def auto_reload_threshold_display(self, obj):
+ return f"${obj.auto_reload_threshold_cents/100:.2f}"
+ auto_reload_threshold_display.short_description = 'Reload Threshold'
+
+
+@admin.register(CreditTransaction)
+class CreditTransactionAdmin(admin.ModelAdmin):
+ list_display = [
+ 'created_at',
+ 'credits',
+ 'amount_display',
+ 'transaction_type',
+ 'description',
+ ]
+ list_filter = ['transaction_type', 'created_at']
+ search_fields = ['credits__tenant__name', 'description']
+ readonly_fields = ['created_at']
+ date_hierarchy = 'created_at'
+
+ def amount_display(self, obj):
+ sign = '+' if obj.amount_cents > 0 else ''
+ return f"{sign}${obj.amount_cents/100:.2f}"
+ amount_display.short_description = 'Amount'
+
+
+@admin.register(ProxyPhoneNumber)
+class ProxyPhoneNumberAdmin(admin.ModelAdmin):
+ list_display = [
+ 'phone_number',
+ 'status',
+ 'assigned_tenant',
+ 'monthly_fee_display',
+ 'is_active',
+ ]
+ list_filter = ['status', 'is_active']
+ search_fields = ['phone_number', 'assigned_tenant__name']
+ readonly_fields = ['twilio_sid', 'created_at', 'updated_at']
+
+ def monthly_fee_display(self, obj):
+ return f"${obj.monthly_fee_cents/100:.2f}"
+ monthly_fee_display.short_description = 'Monthly Fee'
+
+
+@admin.register(MaskedSession)
+class MaskedSessionAdmin(admin.ModelAdmin):
+ list_display = [
+ 'id',
+ 'tenant',
+ 'status',
+ 'customer_phone',
+ 'staff_phone',
+ 'sms_count',
+ 'created_at',
+ 'expires_at',
+ ]
+ list_filter = ['status', 'created_at']
+ search_fields = ['tenant__name', 'customer_phone', 'staff_phone']
+ readonly_fields = ['created_at', 'updated_at']
+ date_hierarchy = 'created_at'
diff --git a/smoothschedule/smoothschedule/comms_credits/apps.py b/smoothschedule/smoothschedule/comms_credits/apps.py
new file mode 100644
index 0000000..e800e82
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class CommsCreditsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'smoothschedule.comms_credits'
+ verbose_name = 'Communication Credits'
diff --git a/smoothschedule/smoothschedule/comms_credits/migrations/0001_initial.py b/smoothschedule/smoothschedule/comms_credits/migrations/0001_initial.py
new file mode 100644
index 0000000..4d604b3
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/migrations/0001_initial.py
@@ -0,0 +1,107 @@
+# Generated by Django 5.2.8 on 2025-12-02 02:56
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('core', '0011_tenant_twilio_phone_number_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CommunicationCredits',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('balance_cents', models.IntegerField(default=0, help_text='Current credit balance in cents')),
+ ('auto_reload_enabled', models.BooleanField(default=False, help_text='Automatically reload credits when balance falls below threshold')),
+ ('auto_reload_threshold_cents', models.IntegerField(default=1000, help_text='Reload when balance falls below this amount (cents)')),
+ ('auto_reload_amount_cents', models.IntegerField(default=2500, help_text='Amount to reload (cents)')),
+ ('low_balance_warning_cents', models.IntegerField(default=500, help_text='Send warning email when balance falls below this amount (cents)')),
+ ('low_balance_warning_sent', models.BooleanField(default=False, help_text='Whether low balance warning has been sent (reset on reload)')),
+ ('low_balance_warning_sent_at', models.DateTimeField(blank=True, help_text='When the last low balance warning was sent', null=True)),
+ ('stripe_payment_method_id', models.CharField(blank=True, default='', help_text='Stripe Payment Method ID for auto-reload', max_length=255)),
+ ('last_twilio_sync_at', models.DateTimeField(blank=True, help_text='When usage was last synced from Twilio', null=True)),
+ ('twilio_sync_period_start', models.DateField(blank=True, help_text='Start of current Twilio billing period', null=True)),
+ ('twilio_raw_usage_cents', models.IntegerField(default=0, help_text='Raw Twilio cost for current period (cents)')),
+ ('billed_usage_cents', models.IntegerField(default=0, help_text='Amount already deducted from credits for current period (cents)')),
+ ('total_loaded_cents', models.IntegerField(default=0, help_text='Total credits ever loaded (cents)')),
+ ('total_spent_cents', models.IntegerField(default=0, help_text='Total credits ever spent (cents)')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='communication_credits', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Communication Credits',
+ 'verbose_name_plural': 'Communication Credits',
+ },
+ ),
+ migrations.CreateModel(
+ name='ProxyPhoneNumber',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('phone_number', models.CharField(help_text='E.164 format phone number', max_length=20, unique=True)),
+ ('twilio_sid', models.CharField(help_text='Twilio Phone Number SID', max_length=50)),
+ ('status', models.CharField(choices=[('available', 'Available'), ('assigned', 'Assigned to Tenant'), ('reserved', 'Reserved for Session'), ('inactive', 'Inactive')], default='available', max_length=20)),
+ ('assigned_at', models.DateTimeField(blank=True, null=True)),
+ ('monthly_fee_cents', models.IntegerField(default=200, help_text='Monthly fee charged to tenant (cents)')),
+ ('last_billed_at', models.DateTimeField(blank=True, help_text='When this number was last billed', null=True)),
+ ('friendly_name', models.CharField(blank=True, default='', help_text='Friendly name for the number', max_length=100)),
+ ('capabilities', models.JSONField(blank=True, default=dict, help_text='Twilio capabilities (voice, sms, mms)')),
+ ('is_active', models.BooleanField(default=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('assigned_tenant', models.ForeignKey(blank=True, help_text='Tenant this number is assigned to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='proxy_phone_numbers', to='core.tenant')),
+ ],
+ options={
+ 'verbose_name': 'Proxy Phone Number',
+ 'verbose_name_plural': 'Proxy Phone Numbers',
+ 'ordering': ['phone_number'],
+ },
+ ),
+ migrations.CreateModel(
+ name='CreditTransaction',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('amount_cents', models.IntegerField(help_text='Amount in cents (positive=credit, negative=debit)')),
+ ('balance_after_cents', models.IntegerField(help_text='Balance after this transaction (cents)')),
+ ('transaction_type', models.CharField(choices=[('manual', 'Manual Top-up'), ('auto_reload', 'Auto Reload'), ('usage', 'Usage'), ('refund', 'Refund'), ('adjustment', 'Adjustment'), ('promo', 'Promotional Credit')], default='usage', max_length=20)),
+ ('description', models.CharField(blank=True, default='', max_length=255)),
+ ('reference_type', models.CharField(blank=True, default='', help_text='Type: sms, voice, proxy_number, etc.', max_length=50)),
+ ('reference_id', models.CharField(blank=True, default='', help_text='External reference (Twilio SID, etc.)', max_length=100)),
+ ('stripe_charge_id', models.CharField(blank=True, default='', help_text='Stripe Charge/PaymentIntent ID', max_length=255)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('credits', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='comms_credits.communicationcredits')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['credits', '-created_at'], name='comms_credi_credits_ae1f83_idx'), models.Index(fields=['transaction_type', '-created_at'], name='comms_credi_transac_c0fc69_idx')],
+ },
+ ),
+ migrations.CreateModel(
+ name='MaskedSession',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('event_id', models.IntegerField(blank=True, help_text='ID of the Event this session is for (in tenant schema)', null=True)),
+ ('customer_phone', models.CharField(help_text="Customer's real phone number", max_length=20)),
+ ('staff_phone', models.CharField(help_text="Staff member's real phone number", max_length=20)),
+ ('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed'), ('expired', 'Expired')], default='active', max_length=20)),
+ ('expires_at', models.DateTimeField(help_text='When this session automatically expires')),
+ ('closed_at', models.DateTimeField(blank=True, null=True)),
+ ('sms_count', models.IntegerField(default=0, help_text='Number of SMS messages sent through this session')),
+ ('voice_seconds', models.IntegerField(default=0, help_text='Total voice call duration in seconds')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='masked_sessions', to='core.tenant')),
+ ('proxy_number', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sessions', to='comms_credits.proxyphonenumber')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['tenant', 'status'], name='comms_credi_tenant__9ddf1c_idx'), models.Index(fields=['proxy_number', 'status'], name='comms_credi_proxy_n_e0fe91_idx'), models.Index(fields=['expires_at'], name='comms_credi_expires_6dd7c7_idx')],
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/comms_credits/migrations/0002_add_stripe_customer_id.py b/smoothschedule/smoothschedule/comms_credits/migrations/0002_add_stripe_customer_id.py
new file mode 100644
index 0000000..770f05a
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/migrations/0002_add_stripe_customer_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.8 on 2025-12-02 04:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comms_credits', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='communicationcredits',
+ name='stripe_customer_id',
+ field=models.CharField(blank=True, default='', help_text='Stripe Customer ID for this tenant', max_length=255),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/comms_credits/migrations/__init__.py b/smoothschedule/smoothschedule/comms_credits/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/smoothschedule/comms_credits/models.py b/smoothschedule/smoothschedule/comms_credits/models.py
new file mode 100644
index 0000000..ec5d440
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/models.py
@@ -0,0 +1,532 @@
+"""
+Communication Credits Models
+
+Prepaid credit system for SMS and voice services.
+Integrates with Twilio subaccounts for usage tracking.
+"""
+from django.db import models
+from django.utils import timezone
+
+
+class CommunicationCredits(models.Model):
+ """
+ Prepaid communication credits for a tenant.
+
+ Credits are used for:
+ - SMS reminders to customers and staff
+ - Masked calling/SMS through proxy numbers
+ - Voice minutes
+
+ Usage is tracked via Twilio subaccounts and synced periodically.
+ """
+ tenant = models.OneToOneField(
+ 'core.Tenant',
+ on_delete=models.CASCADE,
+ related_name='communication_credits'
+ )
+
+ # Current balance (stored in cents for precision)
+ balance_cents = models.IntegerField(
+ default=0,
+ help_text="Current credit balance in cents"
+ )
+
+ # Auto-reload settings
+ auto_reload_enabled = models.BooleanField(
+ default=False,
+ help_text="Automatically reload credits when balance falls below threshold"
+ )
+ auto_reload_threshold_cents = models.IntegerField(
+ default=1000, # $10
+ help_text="Reload when balance falls below this amount (cents)"
+ )
+ auto_reload_amount_cents = models.IntegerField(
+ default=2500, # $25
+ help_text="Amount to reload (cents)"
+ )
+
+ # Notification settings
+ low_balance_warning_cents = models.IntegerField(
+ default=500, # $5
+ help_text="Send warning email when balance falls below this amount (cents)"
+ )
+ low_balance_warning_sent = models.BooleanField(
+ default=False,
+ help_text="Whether low balance warning has been sent (reset on reload)"
+ )
+ low_balance_warning_sent_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When the last low balance warning was sent"
+ )
+
+ # Stripe integration
+ stripe_customer_id = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Customer ID for this tenant"
+ )
+ stripe_payment_method_id = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Payment Method ID for auto-reload"
+ )
+
+ # Twilio usage sync tracking
+ last_twilio_sync_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When usage was last synced from Twilio"
+ )
+ twilio_sync_period_start = models.DateField(
+ null=True,
+ blank=True,
+ help_text="Start of current Twilio billing period"
+ )
+ twilio_raw_usage_cents = models.IntegerField(
+ default=0,
+ help_text="Raw Twilio cost for current period (cents)"
+ )
+ billed_usage_cents = models.IntegerField(
+ default=0,
+ help_text="Amount already deducted from credits for current period (cents)"
+ )
+
+ # Lifetime stats
+ total_loaded_cents = models.IntegerField(
+ default=0,
+ help_text="Total credits ever loaded (cents)"
+ )
+ total_spent_cents = models.IntegerField(
+ default=0,
+ help_text="Total credits ever spent (cents)"
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = 'Communication Credits'
+ verbose_name_plural = 'Communication Credits'
+
+ def __str__(self):
+ return f"{self.tenant.name} - ${self.balance_cents/100:.2f}"
+
+ @property
+ def balance(self):
+ """Balance in dollars"""
+ return self.balance_cents / 100
+
+ @property
+ def auto_reload_threshold(self):
+ """Threshold in dollars"""
+ return self.auto_reload_threshold_cents / 100
+
+ @property
+ def auto_reload_amount(self):
+ """Reload amount in dollars"""
+ return self.auto_reload_amount_cents / 100
+
+ def deduct(self, amount_cents, description, reference_type=None, reference_id=None):
+ """
+ Deduct credits from balance.
+
+ Args:
+ amount_cents: Amount to deduct in cents
+ description: Human-readable description
+ reference_type: Type of reference (e.g., 'sms', 'voice', 'proxy_number')
+ reference_id: External ID (e.g., Twilio SID)
+
+ Returns:
+ CreditTransaction if successful, None if insufficient balance
+ """
+ if self.balance_cents < amount_cents:
+ return None
+
+ self.balance_cents -= amount_cents
+ self.total_spent_cents += amount_cents
+ self.save(update_fields=['balance_cents', 'total_spent_cents', 'updated_at'])
+
+ # Create transaction record
+ transaction = CreditTransaction.objects.create(
+ credits=self,
+ amount_cents=-amount_cents,
+ balance_after_cents=self.balance_cents,
+ transaction_type='usage',
+ description=description,
+ reference_type=reference_type or '',
+ reference_id=reference_id or '',
+ )
+
+ # Check thresholds
+ self._check_thresholds()
+
+ return transaction
+
+ def add_credits(self, amount_cents, transaction_type='manual',
+ stripe_charge_id=None, description=None):
+ """
+ Add credits to balance.
+
+ Args:
+ amount_cents: Amount to add in cents
+ transaction_type: 'manual', 'auto_reload', 'refund', 'adjustment'
+ stripe_charge_id: Stripe charge ID if applicable
+ description: Optional description
+ """
+ self.balance_cents += amount_cents
+ self.total_loaded_cents += amount_cents
+ self.low_balance_warning_sent = False # Reset warning flag
+ self.save(update_fields=[
+ 'balance_cents', 'total_loaded_cents',
+ 'low_balance_warning_sent', 'updated_at'
+ ])
+
+ # Create transaction record
+ CreditTransaction.objects.create(
+ credits=self,
+ amount_cents=amount_cents,
+ balance_after_cents=self.balance_cents,
+ transaction_type=transaction_type,
+ description=description or f"Credits added ({transaction_type})",
+ stripe_charge_id=stripe_charge_id or '',
+ )
+
+ def _check_thresholds(self):
+ """Check and trigger warnings/auto-reload."""
+ # Low balance warning
+ if (self.balance_cents <= self.low_balance_warning_cents
+ and not self.low_balance_warning_sent):
+ self._send_low_balance_warning()
+
+ # Auto-reload
+ if (self.auto_reload_enabled
+ and self.balance_cents <= self.auto_reload_threshold_cents
+ and self.stripe_payment_method_id):
+ self._trigger_auto_reload()
+
+ def _send_low_balance_warning(self):
+ """Send low balance warning email."""
+ from smoothschedule.comms_credits.tasks import send_low_balance_warning
+ send_low_balance_warning.delay(self.id)
+
+ self.low_balance_warning_sent = True
+ self.low_balance_warning_sent_at = timezone.now()
+ self.save(update_fields=['low_balance_warning_sent', 'low_balance_warning_sent_at'])
+
+ def _trigger_auto_reload(self):
+ """Trigger auto-reload of credits."""
+ from smoothschedule.comms_credits.tasks import process_auto_reload
+ process_auto_reload.delay(self.id)
+
+
+class CreditTransaction(models.Model):
+ """
+ Transaction history for communication credits.
+
+ Tracks all credit additions and deductions for auditing
+ and billing reconciliation.
+ """
+
+ class TransactionType(models.TextChoices):
+ MANUAL = 'manual', 'Manual Top-up'
+ AUTO_RELOAD = 'auto_reload', 'Auto Reload'
+ USAGE = 'usage', 'Usage'
+ REFUND = 'refund', 'Refund'
+ ADJUSTMENT = 'adjustment', 'Adjustment'
+ PROMO = 'promo', 'Promotional Credit'
+
+ credits = models.ForeignKey(
+ CommunicationCredits,
+ on_delete=models.CASCADE,
+ related_name='transactions'
+ )
+
+ # Amount (positive = add, negative = deduct)
+ amount_cents = models.IntegerField(
+ help_text="Amount in cents (positive=credit, negative=debit)"
+ )
+ balance_after_cents = models.IntegerField(
+ help_text="Balance after this transaction (cents)"
+ )
+
+ # Transaction type
+ transaction_type = models.CharField(
+ max_length=20,
+ choices=TransactionType.choices,
+ default=TransactionType.USAGE
+ )
+
+ # Description and reference
+ description = models.CharField(
+ max_length=255,
+ blank=True,
+ default=''
+ )
+ reference_type = models.CharField(
+ max_length=50,
+ blank=True,
+ default='',
+ help_text="Type: sms, voice, proxy_number, etc."
+ )
+ reference_id = models.CharField(
+ max_length=100,
+ blank=True,
+ default='',
+ help_text="External reference (Twilio SID, etc.)"
+ )
+
+ # Stripe integration
+ stripe_charge_id = models.CharField(
+ max_length=255,
+ blank=True,
+ default='',
+ help_text="Stripe Charge/PaymentIntent ID"
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['credits', '-created_at']),
+ models.Index(fields=['transaction_type', '-created_at']),
+ ]
+
+ def __str__(self):
+ sign = '+' if self.amount_cents > 0 else ''
+ return f"{sign}${self.amount_cents/100:.2f} - {self.description}"
+
+ @property
+ def amount(self):
+ """Amount in dollars"""
+ return self.amount_cents / 100
+
+
+class ProxyPhoneNumber(models.Model):
+ """
+ Pool of Twilio phone numbers for masked calling.
+
+ Numbers can be:
+ - Unassigned (in pool, available)
+ - Assigned to a tenant (dedicated line)
+ - Reserved for a specific session (temporary masking)
+ """
+
+ class Status(models.TextChoices):
+ AVAILABLE = 'available', 'Available'
+ ASSIGNED = 'assigned', 'Assigned to Tenant'
+ RESERVED = 'reserved', 'Reserved for Session'
+ INACTIVE = 'inactive', 'Inactive'
+
+ phone_number = models.CharField(
+ max_length=20,
+ unique=True,
+ help_text="E.164 format phone number"
+ )
+ twilio_sid = models.CharField(
+ max_length=50,
+ help_text="Twilio Phone Number SID"
+ )
+
+ # Status
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.AVAILABLE
+ )
+
+ # Assignment
+ assigned_tenant = models.ForeignKey(
+ 'core.Tenant',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='proxy_phone_numbers',
+ help_text="Tenant this number is assigned to"
+ )
+ assigned_at = models.DateTimeField(
+ null=True,
+ blank=True
+ )
+
+ # Billing
+ monthly_fee_cents = models.IntegerField(
+ default=200, # $2.00
+ help_text="Monthly fee charged to tenant (cents)"
+ )
+ last_billed_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="When this number was last billed"
+ )
+
+ # Metadata
+ friendly_name = models.CharField(
+ max_length=100,
+ blank=True,
+ default='',
+ help_text="Friendly name for the number"
+ )
+ capabilities = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Twilio capabilities (voice, sms, mms)"
+ )
+
+ is_active = models.BooleanField(default=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['phone_number']
+ verbose_name = 'Proxy Phone Number'
+ verbose_name_plural = 'Proxy Phone Numbers'
+
+ def __str__(self):
+ tenant_info = f" ({self.assigned_tenant.name})" if self.assigned_tenant else ""
+ return f"{self.phone_number}{tenant_info}"
+
+ def assign_to_tenant(self, tenant):
+ """Assign this number to a tenant."""
+ self.assigned_tenant = tenant
+ self.assigned_at = timezone.now()
+ self.status = self.Status.ASSIGNED
+ self.save(update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at'])
+
+ def release(self):
+ """Release this number back to the pool."""
+ self.assigned_tenant = None
+ self.assigned_at = None
+ self.status = self.Status.AVAILABLE
+ self.save(update_fields=['assigned_tenant', 'assigned_at', 'status', 'updated_at'])
+
+
+class MaskedSession(models.Model):
+ """
+ Temporary masked communication session between parties.
+
+ Links a customer and staff member through a proxy number
+ for the duration of an appointment/event.
+ """
+
+ class Status(models.TextChoices):
+ ACTIVE = 'active', 'Active'
+ CLOSED = 'closed', 'Closed'
+ EXPIRED = 'expired', 'Expired'
+
+ tenant = models.ForeignKey(
+ 'core.Tenant',
+ on_delete=models.CASCADE,
+ related_name='masked_sessions'
+ )
+
+ # The event this session is for (stored as ID since Event is in tenant schema)
+ event_id = models.IntegerField(
+ null=True,
+ blank=True,
+ help_text="ID of the Event this session is for (in tenant schema)"
+ )
+
+ # Proxy number used for this session
+ proxy_number = models.ForeignKey(
+ ProxyPhoneNumber,
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name='sessions'
+ )
+
+ # Participant phone numbers
+ customer_phone = models.CharField(
+ max_length=20,
+ help_text="Customer's real phone number"
+ )
+ staff_phone = models.CharField(
+ max_length=20,
+ help_text="Staff member's real phone number"
+ )
+
+ # Status
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.ACTIVE
+ )
+
+ # Timing
+ expires_at = models.DateTimeField(
+ help_text="When this session automatically expires"
+ )
+ closed_at = models.DateTimeField(
+ null=True,
+ blank=True
+ )
+
+ # Usage tracking
+ sms_count = models.IntegerField(
+ default=0,
+ help_text="Number of SMS messages sent through this session"
+ )
+ voice_seconds = models.IntegerField(
+ default=0,
+ help_text="Total voice call duration in seconds"
+ )
+
+ # Timestamps
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['tenant', 'status']),
+ models.Index(fields=['proxy_number', 'status']),
+ models.Index(fields=['expires_at']),
+ ]
+
+ def __str__(self):
+ return f"Session {self.id}: {self.customer_phone} <-> {self.staff_phone}"
+
+ def is_active(self):
+ """Check if session is still active."""
+ if self.status != self.Status.ACTIVE:
+ return False
+ if timezone.now() >= self.expires_at:
+ return False
+ return True
+
+ def close(self):
+ """Close this session."""
+ self.status = self.Status.CLOSED
+ self.closed_at = timezone.now()
+ self.save(update_fields=['status', 'closed_at', 'updated_at'])
+
+ # Release proxy number back to pool if it was reserved for this session
+ if self.proxy_number and self.proxy_number.status == ProxyPhoneNumber.Status.RESERVED:
+ self.proxy_number.status = ProxyPhoneNumber.Status.AVAILABLE
+ self.proxy_number.save(update_fields=['status', 'updated_at'])
+
+ def get_destination_for_caller(self, caller_phone):
+ """
+ Determine where to route a call/SMS based on who's calling.
+
+ Args:
+ caller_phone: The phone number of the caller
+
+ Returns:
+ The phone number to forward to, or None if caller not in session
+ """
+ # Normalize phone numbers for comparison
+ caller_normalized = caller_phone.replace('+', '').replace('-', '').replace(' ', '')
+ customer_normalized = self.customer_phone.replace('+', '').replace('-', '').replace(' ', '')
+ staff_normalized = self.staff_phone.replace('+', '').replace('-', '').replace(' ', '')
+
+ if caller_normalized.endswith(customer_normalized) or customer_normalized.endswith(caller_normalized):
+ return self.staff_phone
+ elif caller_normalized.endswith(staff_normalized) or staff_normalized.endswith(caller_normalized):
+ return self.customer_phone
+ return None
diff --git a/smoothschedule/smoothschedule/comms_credits/tasks.py b/smoothschedule/smoothschedule/comms_credits/tasks.py
new file mode 100644
index 0000000..43cb2e4
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/tasks.py
@@ -0,0 +1,418 @@
+"""
+Celery tasks for communication credits and Twilio integration.
+"""
+import logging
+from celery import shared_task
+from django.conf import settings
+from django.utils import timezone
+from datetime import timedelta
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task
+def sync_twilio_usage_all_tenants():
+ """
+ Sync Twilio usage for all tenants with subaccounts.
+
+ Runs periodically (e.g., hourly) to:
+ 1. Fetch usage from each Twilio subaccount
+ 2. Calculate charges with markup
+ 3. Deduct from tenant credits
+ """
+ from core.models import Tenant
+
+ tenants = Tenant.objects.exclude(twilio_subaccount_sid='')
+
+ synced = 0
+ errors = 0
+
+ for tenant in tenants:
+ try:
+ sync_twilio_usage_for_tenant.delay(tenant.id)
+ synced += 1
+ except Exception as e:
+ logger.error(f"Error queuing sync for tenant {tenant.name}: {e}")
+ errors += 1
+
+ logger.info(f"Queued Twilio sync for {synced} tenants, {errors} errors")
+ return {'synced': synced, 'errors': errors}
+
+
+@shared_task
+def sync_twilio_usage_for_tenant(tenant_id):
+ """
+ Sync Twilio usage for a specific tenant.
+
+ Fetches usage from Twilio API and deducts from credits.
+ """
+ from core.models import Tenant
+ from .models import CommunicationCredits
+
+ try:
+ tenant = Tenant.objects.get(id=tenant_id)
+ except Tenant.DoesNotExist:
+ logger.error(f"Tenant {tenant_id} not found")
+ return {'error': 'Tenant not found'}
+
+ if not tenant.twilio_subaccount_sid:
+ return {'error': 'No Twilio subaccount configured'}
+
+ # Get or create credits for tenant
+ credits, created = CommunicationCredits.objects.get_or_create(tenant=tenant)
+
+ try:
+ from twilio.rest import Client
+
+ # Initialize Twilio client with subaccount credentials
+ client = Client(
+ tenant.twilio_subaccount_sid,
+ tenant.twilio_subaccount_auth_token
+ )
+
+ # Get this month's usage
+ today = timezone.now().date()
+ first_of_month = today.replace(day=1)
+
+ records = client.usage.records.this_month.list()
+
+ # Calculate total Twilio cost
+ total_twilio_cost_cents = 0
+ usage_breakdown = {}
+
+ for record in records:
+ cost_cents = int(float(record.price) * 100)
+ total_twilio_cost_cents += cost_cents
+ usage_breakdown[record.category] = {
+ 'usage': str(record.usage),
+ 'cost_cents': cost_cents,
+ }
+
+ # Apply markup (e.g., 50% margin)
+ # This should come from the tenant's subscription plan
+ markup_multiplier = getattr(settings, 'COMMS_MARKUP_MULTIPLIER', 1.5)
+ total_with_markup_cents = int(total_twilio_cost_cents * markup_multiplier)
+
+ # Calculate new charges since last sync
+ new_charges = total_with_markup_cents - credits.billed_usage_cents
+
+ if new_charges > 0:
+ # Deduct from credits
+ credits.deduct(
+ new_charges,
+ f"Twilio usage for {first_of_month.strftime('%B %Y')}",
+ reference_type='twilio_sync',
+ reference_id=f"{first_of_month.isoformat()}"
+ )
+
+ # Update sync tracking
+ credits.billed_usage_cents = total_with_markup_cents
+
+ # Update sync info
+ credits.last_twilio_sync_at = timezone.now()
+ credits.twilio_sync_period_start = first_of_month
+ credits.twilio_raw_usage_cents = total_twilio_cost_cents
+ credits.save(update_fields=[
+ 'last_twilio_sync_at', 'twilio_sync_period_start',
+ 'twilio_raw_usage_cents', 'billed_usage_cents'
+ ])
+
+ logger.info(
+ f"Synced Twilio usage for {tenant.name}: "
+ f"${total_twilio_cost_cents/100:.2f} raw, "
+ f"${total_with_markup_cents/100:.2f} with markup, "
+ f"${new_charges/100:.2f} new charges"
+ )
+
+ return {
+ 'tenant': tenant.name,
+ 'raw_cost_cents': total_twilio_cost_cents,
+ 'billed_cents': total_with_markup_cents,
+ 'new_charges_cents': new_charges,
+ 'usage_breakdown': usage_breakdown,
+ }
+
+ except Exception as e:
+ logger.error(f"Error syncing Twilio usage for {tenant.name}: {e}")
+ return {'error': str(e)}
+
+
+@shared_task
+def send_low_balance_warning(credits_id):
+ """
+ Send low balance warning email to tenant.
+ """
+ from .models import CommunicationCredits
+ from django.core.mail import send_mail
+
+ try:
+ credits = CommunicationCredits.objects.select_related('tenant').get(id=credits_id)
+ except CommunicationCredits.DoesNotExist:
+ return {'error': 'Credits not found'}
+
+ tenant = credits.tenant
+
+ # Get tenant owner's email
+ owner = tenant.users.filter(role='owner').first()
+ if not owner:
+ logger.warning(f"No owner found for tenant {tenant.name}")
+ return {'error': 'No owner email'}
+
+ subject = f"Low Communication Credits Balance - {tenant.name}"
+ message = f"""
+Hi {owner.first_name or owner.username},
+
+Your communication credits balance for {tenant.name} is running low.
+
+Current Balance: ${credits.balance_cents/100:.2f}
+Warning Threshold: ${credits.low_balance_warning_cents/100:.2f}
+
+{"Auto-reload is ENABLED and will trigger at $" + f"{credits.auto_reload_threshold_cents/100:.2f}" if credits.auto_reload_enabled else "Auto-reload is NOT enabled. Please add credits to avoid service interruption."}
+
+To add credits or manage your settings, visit your Communication Settings page.
+
+Best regards,
+SmoothSchedule Team
+"""
+
+ try:
+ send_mail(
+ subject,
+ message,
+ settings.DEFAULT_FROM_EMAIL,
+ [owner.email],
+ fail_silently=False,
+ )
+ logger.info(f"Sent low balance warning to {owner.email} for {tenant.name}")
+ return {'sent_to': owner.email}
+ except Exception as e:
+ logger.error(f"Error sending low balance warning: {e}")
+ return {'error': str(e)}
+
+
+@shared_task
+def process_auto_reload(credits_id):
+ """
+ Process auto-reload of credits.
+
+ Charges the stored payment method and adds credits.
+ """
+ from .models import CommunicationCredits
+ import stripe
+
+ try:
+ credits = CommunicationCredits.objects.select_related('tenant').get(id=credits_id)
+ except CommunicationCredits.DoesNotExist:
+ return {'error': 'Credits not found'}
+
+ if not credits.auto_reload_enabled:
+ return {'error': 'Auto-reload not enabled'}
+
+ if not credits.stripe_payment_method_id:
+ return {'error': 'No payment method'}
+
+ if credits.balance_cents > credits.auto_reload_threshold_cents:
+ return {'skipped': 'Balance above threshold'}
+
+ tenant = credits.tenant
+ amount_cents = credits.auto_reload_amount_cents
+
+ try:
+ # Get Stripe API key from platform settings
+ from platform_admin.models import PlatformSettings
+ platform_settings = PlatformSettings.get_instance()
+ stripe.api_key = platform_settings.get_stripe_secret_key()
+
+ # Create payment intent and confirm
+ payment_intent = stripe.PaymentIntent.create(
+ amount=amount_cents,
+ currency='usd',
+ payment_method=credits.stripe_payment_method_id,
+ confirm=True,
+ description=f"Communication credits reload for {tenant.name}",
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'tenant_name': tenant.name,
+ 'type': 'comms_credits_reload',
+ },
+ # Auto-confirm with the stored payment method
+ automatic_payment_methods={
+ 'enabled': True,
+ 'allow_redirects': 'never',
+ },
+ )
+
+ if payment_intent.status == 'succeeded':
+ # Add credits
+ credits.add_credits(
+ amount_cents,
+ transaction_type='auto_reload',
+ stripe_charge_id=payment_intent.id,
+ description=f"Auto-reload: ${amount_cents/100:.2f}"
+ )
+
+ logger.info(
+ f"Auto-reloaded ${amount_cents/100:.2f} for {tenant.name}, "
+ f"new balance: ${credits.balance_cents/100:.2f}"
+ )
+
+ return {
+ 'success': True,
+ 'amount_cents': amount_cents,
+ 'new_balance_cents': credits.balance_cents,
+ 'payment_intent_id': payment_intent.id,
+ }
+ else:
+ logger.error(f"Payment failed for {tenant.name}: {payment_intent.status}")
+ return {'error': f"Payment status: {payment_intent.status}"}
+
+ except stripe.error.CardError as e:
+ logger.error(f"Card error for {tenant.name}: {e.error.message}")
+ return {'error': f"Card error: {e.error.message}"}
+ except Exception as e:
+ logger.error(f"Error processing auto-reload for {tenant.name}: {e}")
+ return {'error': str(e)}
+
+
+@shared_task
+def bill_proxy_phone_numbers():
+ """
+ Monthly task to bill tenants for assigned proxy phone numbers.
+ """
+ from .models import ProxyPhoneNumber, CommunicationCredits
+
+ today = timezone.now()
+ first_of_month = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+
+ # Get all assigned numbers that haven't been billed this month
+ numbers = ProxyPhoneNumber.objects.filter(
+ status=ProxyPhoneNumber.Status.ASSIGNED,
+ is_active=True,
+ ).exclude(
+ last_billed_at__gte=first_of_month
+ ).select_related('assigned_tenant')
+
+ billed = 0
+ errors = 0
+
+ for number in numbers:
+ tenant = number.assigned_tenant
+ if not tenant:
+ continue
+
+ try:
+ credits, _ = CommunicationCredits.objects.get_or_create(tenant=tenant)
+
+ # Deduct monthly fee
+ result = credits.deduct(
+ number.monthly_fee_cents,
+ f"Proxy number {number.phone_number} - {today.strftime('%B %Y')}",
+ reference_type='proxy_number',
+ reference_id=number.phone_number,
+ )
+
+ if result:
+ number.last_billed_at = today
+ number.save(update_fields=['last_billed_at', 'updated_at'])
+ billed += 1
+ logger.info(
+ f"Billed ${number.monthly_fee_cents/100:.2f} for {number.phone_number} "
+ f"to {tenant.name}"
+ )
+ else:
+ logger.warning(
+ f"Insufficient credits for {tenant.name} to bill {number.phone_number}"
+ )
+ errors += 1
+
+ except Exception as e:
+ logger.error(f"Error billing {number.phone_number}: {e}")
+ errors += 1
+
+ logger.info(f"Billed {billed} proxy numbers, {errors} errors")
+ return {'billed': billed, 'errors': errors}
+
+
+@shared_task
+def expire_masked_sessions():
+ """
+ Close expired masked sessions and release proxy numbers.
+ """
+ from .models import MaskedSession
+
+ expired = MaskedSession.objects.filter(
+ status=MaskedSession.Status.ACTIVE,
+ expires_at__lt=timezone.now()
+ )
+
+ count = 0
+ for session in expired:
+ session.status = MaskedSession.Status.EXPIRED
+ session.closed_at = timezone.now()
+ session.save(update_fields=['status', 'closed_at', 'updated_at'])
+
+ # Release proxy number if reserved
+ if session.proxy_number:
+ from .models import ProxyPhoneNumber
+ if session.proxy_number.status == ProxyPhoneNumber.Status.RESERVED:
+ session.proxy_number.status = ProxyPhoneNumber.Status.AVAILABLE
+ session.proxy_number.save(update_fields=['status', 'updated_at'])
+
+ count += 1
+
+ if count:
+ logger.info(f"Expired {count} masked sessions")
+
+ return {'expired': count}
+
+
+@shared_task
+def create_twilio_subaccount(tenant_id):
+ """
+ Create a Twilio subaccount for a tenant.
+
+ Called when SMS/calling is first enabled for a tenant.
+ """
+ from core.models import Tenant
+ from twilio.rest import Client
+
+ try:
+ tenant = Tenant.objects.get(id=tenant_id)
+ except Tenant.DoesNotExist:
+ return {'error': 'Tenant not found'}
+
+ if tenant.twilio_subaccount_sid:
+ return {'skipped': 'Subaccount already exists'}
+
+ try:
+ # Get master Twilio credentials from settings
+ master_sid = getattr(settings, 'TWILIO_ACCOUNT_SID', '')
+ master_token = getattr(settings, 'TWILIO_AUTH_TOKEN', '')
+
+ if not master_sid or not master_token:
+ return {'error': 'Twilio master credentials not configured'}
+
+ client = Client(master_sid, master_token)
+
+ # Create subaccount
+ subaccount = client.api.accounts.create(
+ friendly_name=f"SmoothSchedule - {tenant.name}"
+ )
+
+ # Save to tenant
+ tenant.twilio_subaccount_sid = subaccount.sid
+ tenant.twilio_subaccount_auth_token = subaccount.auth_token
+ tenant.save(update_fields=[
+ 'twilio_subaccount_sid', 'twilio_subaccount_auth_token'
+ ])
+
+ logger.info(f"Created Twilio subaccount for {tenant.name}: {subaccount.sid}")
+
+ return {
+ 'success': True,
+ 'subaccount_sid': subaccount.sid,
+ }
+
+ except Exception as e:
+ logger.error(f"Error creating Twilio subaccount for {tenant.name}: {e}")
+ return {'error': str(e)}
diff --git a/smoothschedule/smoothschedule/comms_credits/urls.py b/smoothschedule/smoothschedule/comms_credits/urls.py
new file mode 100644
index 0000000..6c0c12d
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/urls.py
@@ -0,0 +1,43 @@
+"""
+Communication Credits URL Configuration
+"""
+from django.urls import path
+
+from .views import (
+ get_credits_view,
+ update_settings_view,
+ add_credits_view,
+ create_payment_intent_view,
+ confirm_payment_view,
+ setup_payment_method_view,
+ save_payment_method_view,
+ get_transactions_view,
+ get_usage_stats_view,
+)
+
+app_name = 'comms_credits'
+
+urlpatterns = [
+ # Get current credits and settings
+ path('', get_credits_view, name='get_credits'),
+
+ # Update settings
+ path('settings/', update_settings_view, name='update_settings'),
+
+ # Add credits (direct payment)
+ path('add/', add_credits_view, name='add_credits'),
+
+ # Stripe Elements payment flow
+ path('create-payment-intent/', create_payment_intent_view, name='create_payment_intent'),
+ path('confirm-payment/', confirm_payment_view, name='confirm_payment'),
+
+ # Setup payment method for auto-reload
+ path('setup-payment-method/', setup_payment_method_view, name='setup_payment_method'),
+ path('save-payment-method/', save_payment_method_view, name='save_payment_method'),
+
+ # Transaction history
+ path('transactions/', get_transactions_view, name='transactions'),
+
+ # Usage stats
+ path('usage-stats/', get_usage_stats_view, name='usage_stats'),
+]
diff --git a/smoothschedule/smoothschedule/comms_credits/views.py b/smoothschedule/smoothschedule/comms_credits/views.py
new file mode 100644
index 0000000..b344b8b
--- /dev/null
+++ b/smoothschedule/smoothschedule/comms_credits/views.py
@@ -0,0 +1,575 @@
+"""
+Communication Credits API Views
+
+API endpoints for managing prepaid communication credits.
+Integrates with Stripe for payments.
+"""
+import stripe
+from django.conf import settings
+from django.db import transaction
+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 rest_framework.pagination import PageNumberPagination
+
+from .models import CommunicationCredits, CreditTransaction
+
+# Initialize Stripe
+stripe.api_key = settings.STRIPE_SECRET_KEY
+
+
+class TransactionPagination(PageNumberPagination):
+ page_size = 20
+ page_size_query_param = 'limit'
+ max_page_size = 100
+
+
+def get_or_create_credits(tenant):
+ """Get or create CommunicationCredits for a tenant."""
+ credits, created = CommunicationCredits.objects.get_or_create(
+ tenant=tenant
+ )
+ return credits
+
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def get_credits_view(request):
+ """
+ Get current communication credits for the business.
+
+ Returns the credit balance and settings.
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ return Response({
+ 'id': credits.id,
+ 'balance_cents': credits.balance_cents,
+ 'auto_reload_enabled': credits.auto_reload_enabled,
+ 'auto_reload_threshold_cents': credits.auto_reload_threshold_cents,
+ 'auto_reload_amount_cents': credits.auto_reload_amount_cents,
+ 'low_balance_warning_cents': credits.low_balance_warning_cents,
+ 'low_balance_warning_sent': credits.low_balance_warning_sent,
+ 'stripe_payment_method_id': credits.stripe_payment_method_id,
+ 'last_twilio_sync_at': credits.last_twilio_sync_at,
+ 'total_loaded_cents': credits.total_loaded_cents,
+ 'total_spent_cents': credits.total_spent_cents,
+ 'created_at': credits.created_at,
+ 'updated_at': credits.updated_at,
+ })
+
+
+@api_view(['PATCH'])
+@permission_classes([IsAuthenticated])
+def update_settings_view(request):
+ """
+ Update credit settings (auto-reload, thresholds, etc.)
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ # Allowed fields to update
+ allowed_fields = [
+ 'auto_reload_enabled',
+ 'auto_reload_threshold_cents',
+ 'auto_reload_amount_cents',
+ 'low_balance_warning_cents',
+ 'stripe_payment_method_id',
+ ]
+
+ update_fields = ['updated_at']
+ for field in allowed_fields:
+ if field in request.data:
+ setattr(credits, field, request.data[field])
+ update_fields.append(field)
+
+ credits.save(update_fields=update_fields)
+
+ return Response({
+ 'id': credits.id,
+ 'balance_cents': credits.balance_cents,
+ 'auto_reload_enabled': credits.auto_reload_enabled,
+ 'auto_reload_threshold_cents': credits.auto_reload_threshold_cents,
+ 'auto_reload_amount_cents': credits.auto_reload_amount_cents,
+ 'low_balance_warning_cents': credits.low_balance_warning_cents,
+ 'low_balance_warning_sent': credits.low_balance_warning_sent,
+ 'stripe_payment_method_id': credits.stripe_payment_method_id,
+ 'last_twilio_sync_at': credits.last_twilio_sync_at,
+ 'total_loaded_cents': credits.total_loaded_cents,
+ 'total_spent_cents': credits.total_spent_cents,
+ 'created_at': credits.created_at,
+ 'updated_at': credits.updated_at,
+ })
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def add_credits_view(request):
+ """
+ Add credits via Stripe payment.
+
+ Expects:
+ - amount_cents: Amount to add (in cents, minimum $5 = 500 cents)
+ - payment_method_id: Stripe Payment Method ID
+ - save_payment_method: Optional, whether to save for auto-reload
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ amount_cents = request.data.get('amount_cents')
+ payment_method_id = request.data.get('payment_method_id')
+ save_payment_method = request.data.get('save_payment_method', False)
+
+ if not amount_cents or amount_cents < 500:
+ return Response(
+ {'error': 'Minimum amount is $5.00 (500 cents)'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ if not payment_method_id:
+ return Response(
+ {'error': 'Payment method is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ try:
+ # Get or create a Stripe customer for this tenant
+ stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
+
+ # Attach payment method to customer if not already attached
+ try:
+ stripe.PaymentMethod.attach(
+ payment_method_id,
+ customer=stripe_customer_id,
+ )
+ except stripe.error.InvalidRequestError as e:
+ # Payment method might already be attached
+ if 'already been attached' not in str(e):
+ raise
+
+ # Create payment intent
+ payment_intent = stripe.PaymentIntent.create(
+ amount=amount_cents,
+ currency='usd',
+ customer=stripe_customer_id,
+ payment_method=payment_method_id,
+ confirm=True,
+ automatic_payment_methods={
+ 'enabled': True,
+ 'allow_redirects': 'never',
+ },
+ description=f'Communication credits for {tenant.name}',
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'tenant_name': tenant.name,
+ 'type': 'communication_credits',
+ },
+ )
+
+ if payment_intent.status == 'succeeded':
+ # Add credits to balance
+ with transaction.atomic():
+ credits.add_credits(
+ amount_cents=amount_cents,
+ transaction_type='manual',
+ stripe_charge_id=payment_intent.id,
+ description=f'Added ${amount_cents/100:.2f} via Stripe'
+ )
+
+ # Save payment method for auto-reload if requested
+ if save_payment_method:
+ credits.stripe_payment_method_id = payment_method_id
+ credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
+
+ return Response({
+ 'success': True,
+ 'balance_cents': credits.balance_cents,
+ 'payment_intent_id': payment_intent.id,
+ })
+
+ elif payment_intent.status == 'requires_action':
+ # 3D Secure or additional authentication required
+ return Response({
+ 'requires_action': True,
+ 'payment_intent_client_secret': payment_intent.client_secret,
+ })
+
+ else:
+ return Response(
+ {'error': f'Payment failed: {payment_intent.status}'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ except stripe.error.CardError as e:
+ return Response(
+ {'error': e.user_message or 'Card was declined'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': 'Payment processing error. Please try again.'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def create_payment_intent_view(request):
+ """
+ Create a Stripe PaymentIntent for credit purchase.
+
+ Use this for payment flows that need client-side confirmation.
+ Returns the client_secret for Stripe Elements.
+
+ Expects:
+ - amount_cents: Amount to add (in cents, minimum $5 = 500 cents)
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ amount_cents = request.data.get('amount_cents')
+
+ if not amount_cents or amount_cents < 500:
+ return Response(
+ {'error': 'Minimum amount is $5.00 (500 cents)'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ try:
+ # Get or create a Stripe customer for this tenant
+ stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
+
+ # Create payment intent
+ payment_intent = stripe.PaymentIntent.create(
+ amount=amount_cents,
+ currency='usd',
+ customer=stripe_customer_id,
+ automatic_payment_methods={
+ 'enabled': True,
+ },
+ description=f'Communication credits for {tenant.name}',
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'tenant_name': tenant.name,
+ 'type': 'communication_credits',
+ },
+ )
+
+ return Response({
+ 'client_secret': payment_intent.client_secret,
+ 'payment_intent_id': payment_intent.id,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': 'Failed to create payment. Please try again.'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def confirm_payment_view(request):
+ """
+ Confirm a payment after client-side processing.
+
+ Called after Stripe Elements confirms the payment on the client.
+
+ Expects:
+ - payment_intent_id: The PaymentIntent ID
+ - save_payment_method: Optional, whether to save for auto-reload
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ payment_intent_id = request.data.get('payment_intent_id')
+ save_payment_method = request.data.get('save_payment_method', False)
+
+ if not payment_intent_id:
+ return Response(
+ {'error': 'Payment intent ID is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ try:
+ # Retrieve the payment intent
+ payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
+
+ # Verify the payment is for this tenant
+ if payment_intent.metadata.get('tenant_id') != str(tenant.id):
+ return Response(
+ {'error': 'Invalid payment'},
+ status=status.HTTP_403_FORBIDDEN
+ )
+
+ if payment_intent.status != 'succeeded':
+ return Response(
+ {'error': f'Payment not completed: {payment_intent.status}'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ # Check if we've already processed this payment
+ existing = CreditTransaction.objects.filter(
+ credits=credits,
+ stripe_charge_id=payment_intent_id
+ ).exists()
+
+ if existing:
+ return Response({
+ 'success': True,
+ 'balance_cents': credits.balance_cents,
+ 'already_processed': True,
+ })
+
+ # Add credits
+ with transaction.atomic():
+ credits.add_credits(
+ amount_cents=payment_intent.amount,
+ transaction_type='manual',
+ stripe_charge_id=payment_intent_id,
+ description=f'Added ${payment_intent.amount/100:.2f} via Stripe'
+ )
+
+ # Save payment method for auto-reload if requested
+ if save_payment_method and payment_intent.payment_method:
+ credits.stripe_payment_method_id = payment_intent.payment_method
+ credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
+
+ return Response({
+ 'success': True,
+ 'balance_cents': credits.balance_cents,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': 'Failed to confirm payment'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def setup_payment_method_view(request):
+ """
+ Create a SetupIntent for saving a payment method for auto-reload.
+
+ Returns the client_secret for Stripe Elements to collect card details
+ without charging immediately.
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ try:
+ stripe_customer_id = _get_or_create_stripe_customer(credits, tenant, request.user)
+
+ setup_intent = stripe.SetupIntent.create(
+ customer=stripe_customer_id,
+ automatic_payment_methods={
+ 'enabled': True,
+ },
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'type': 'communication_credits_auto_reload',
+ },
+ )
+
+ return Response({
+ 'client_secret': setup_intent.client_secret,
+ })
+
+ except stripe.error.StripeError as e:
+ return Response(
+ {'error': 'Failed to set up payment method'},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+@api_view(['POST'])
+@permission_classes([IsAuthenticated])
+def save_payment_method_view(request):
+ """
+ Save a payment method for auto-reload after SetupIntent confirmation.
+
+ Expects:
+ - payment_method_id: The confirmed payment method ID
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ payment_method_id = request.data.get('payment_method_id')
+
+ if not payment_method_id:
+ return Response(
+ {'error': 'Payment method ID is required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+ credits.stripe_payment_method_id = payment_method_id
+ credits.save(update_fields=['stripe_payment_method_id', 'updated_at'])
+
+ return Response({
+ 'success': True,
+ 'payment_method_id': payment_method_id,
+ })
+
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def get_transactions_view(request):
+ """
+ Get credit transaction history with pagination.
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ credits = get_or_create_credits(tenant)
+
+ transactions = CreditTransaction.objects.filter(credits=credits)
+
+ paginator = TransactionPagination()
+ page = paginator.paginate_queryset(transactions, request)
+
+ results = [{
+ 'id': t.id,
+ 'amount_cents': t.amount_cents,
+ 'balance_after_cents': t.balance_after_cents,
+ 'transaction_type': t.transaction_type,
+ 'description': t.description,
+ 'reference_type': t.reference_type,
+ 'reference_id': t.reference_id,
+ 'stripe_charge_id': t.stripe_charge_id,
+ 'created_at': t.created_at,
+ } for t in page]
+
+ return paginator.get_paginated_response(results)
+
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def get_usage_stats_view(request):
+ """
+ Get communication usage statistics for the current month.
+ """
+ tenant = request.tenant
+ if not tenant:
+ return Response(
+ {'error': 'No business context'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ from django.utils import timezone
+ from datetime import timedelta
+ from .models import ProxyPhoneNumber
+
+ credits = get_or_create_credits(tenant)
+
+ # Get usage for current month from transactions
+ now = timezone.now()
+ month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+
+ month_transactions = CreditTransaction.objects.filter(
+ credits=credits,
+ transaction_type='usage',
+ created_at__gte=month_start
+ )
+
+ # Calculate stats
+ sms_transactions = month_transactions.filter(reference_type='sms')
+ voice_transactions = month_transactions.filter(reference_type='voice')
+
+ sms_count = sms_transactions.count()
+
+ # Estimate voice minutes from cost (rough estimate)
+ voice_cost_cents = abs(sum(t.amount_cents for t in voice_transactions))
+ voice_minutes = int(voice_cost_cents / 1.3) # ~$0.013/min Twilio rate
+
+ # Count active proxy numbers
+ active_proxy_numbers = ProxyPhoneNumber.objects.filter(
+ assigned_tenant=tenant,
+ status='assigned',
+ is_active=True
+ ).count()
+
+ # Total estimated cost this month
+ estimated_cost = abs(sum(t.amount_cents for t in month_transactions))
+
+ return Response({
+ 'sms_sent_this_month': sms_count,
+ 'voice_minutes_this_month': voice_minutes,
+ 'proxy_numbers_active': active_proxy_numbers,
+ 'estimated_cost_cents': estimated_cost,
+ })
+
+
+def _get_or_create_stripe_customer(credits, tenant, user):
+ """
+ Get or create a Stripe customer for the tenant.
+
+ Stores the Stripe customer ID on the CommunicationCredits model.
+ """
+ # Check if we already have a Stripe customer
+ if credits.stripe_customer_id:
+ return credits.stripe_customer_id
+
+ # Create a new Stripe customer
+ customer = stripe.Customer.create(
+ email=user.email,
+ name=tenant.name,
+ metadata={
+ 'tenant_id': str(tenant.id),
+ 'tenant_name': tenant.name,
+ },
+ )
+
+ # Store the customer ID on the credits model
+ credits.stripe_customer_id = customer.id
+ credits.save(update_fields=['stripe_customer_id', 'updated_at'])
+
+ return customer.id
diff --git a/smoothschedule/smoothschedule/schedule/management/commands/seed_email_templates.py b/smoothschedule/smoothschedule/schedule/management/commands/seed_email_templates.py
deleted file mode 100644
index 57611d8..0000000
--- a/smoothschedule/smoothschedule/schedule/management/commands/seed_email_templates.py
+++ /dev/null
@@ -1,940 +0,0 @@
-"""
-Management command to seed default email templates.
-
-These templates are created for new businesses and can be customized.
-Platform templates are shared across all tenants.
-
-Usage:
- # Seed templates for all schemas
- python manage.py seed_email_templates
-
- # Seed templates for a specific schema
- python manage.py seed_email_templates --schema=demo
-
- # Force reset to defaults (overwrites existing)
- python manage.py seed_email_templates --reset
-"""
-from django.core.management.base import BaseCommand
-from django.db import connection
-from django_tenants.utils import schema_context, get_tenant_model
-
-
-class Command(BaseCommand):
- help = 'Seed default email templates for tenants'
-
- def add_arguments(self, parser):
- parser.add_argument(
- '--schema',
- type=str,
- help='Specific tenant schema to seed (default: all tenants)',
- )
- parser.add_argument(
- '--reset',
- action='store_true',
- help='Reset templates to defaults (overwrites existing)',
- )
-
- def handle(self, *args, **options):
- schema = options.get('schema')
- reset = options.get('reset', False)
-
- if schema:
- # Seed specific schema
- self.seed_schema(schema, reset)
- else:
- # Seed all tenant schemas
- Tenant = get_tenant_model()
- tenants = Tenant.objects.exclude(schema_name='public')
-
- for tenant in tenants:
- self.seed_schema(tenant.schema_name, reset)
-
- self.stdout.write(self.style.SUCCESS('Email templates seeded successfully!'))
-
- def seed_schema(self, schema_name, reset=False):
- """Seed templates for a specific schema"""
- self.stdout.write(f'Seeding templates for schema: {schema_name}')
-
- with schema_context(schema_name):
- from schedule.models import EmailTemplate
-
- templates = self.get_default_templates()
-
- for template_data in templates:
- name = template_data['name']
-
- if reset:
- # Delete existing and recreate
- EmailTemplate.objects.filter(name=name).delete()
- EmailTemplate.objects.create(**template_data)
- self.stdout.write(f' Reset: {name}')
- else:
- # Only create if doesn't exist
- _, created = EmailTemplate.objects.get_or_create(
- name=name,
- defaults=template_data
- )
- if created:
- self.stdout.write(f' Created: {name}')
- else:
- self.stdout.write(f' Skipped (exists): {name}')
-
- def get_default_templates(self):
- """Return list of default email templates"""
- return [
- # ========== CONFIRMATION TEMPLATES ==========
- {
- 'name': 'Appointment Confirmation',
- 'description': 'Sent when a customer books an appointment',
- 'category': 'CONFIRMATION',
- 'scope': 'BUSINESS',
- 'subject': 'Your appointment at {{BUSINESS_NAME}} is confirmed!',
- 'html_content': self.get_appointment_confirmation_html(),
- 'text_content': self.get_appointment_confirmation_text(),
- },
-
- # ========== REMINDER TEMPLATES ==========
- {
- 'name': 'Appointment Reminder - 24 Hours',
- 'description': 'Reminder sent 24 hours before appointment',
- 'category': 'REMINDER',
- 'scope': 'BUSINESS',
- 'subject': 'Reminder: Your appointment tomorrow at {{BUSINESS_NAME}}',
- 'html_content': self.get_appointment_reminder_html('24 hours'),
- 'text_content': self.get_appointment_reminder_text('24 hours'),
- },
- {
- 'name': 'Appointment Reminder - 1 Hour',
- 'description': 'Reminder sent 1 hour before appointment',
- 'category': 'REMINDER',
- 'scope': 'BUSINESS',
- 'subject': 'Reminder: Your appointment in 1 hour at {{BUSINESS_NAME}}',
- 'html_content': self.get_appointment_reminder_html('1 hour'),
- 'text_content': self.get_appointment_reminder_text('1 hour'),
- },
-
- # ========== NOTIFICATION TEMPLATES ==========
- {
- 'name': 'Appointment Rescheduled',
- 'description': 'Sent when an appointment is rescheduled',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': 'Your appointment at {{BUSINESS_NAME}} has been rescheduled',
- 'html_content': self.get_appointment_rescheduled_html(),
- 'text_content': self.get_appointment_rescheduled_text(),
- },
- {
- 'name': 'Appointment Cancelled',
- 'description': 'Sent when an appointment is cancelled',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': 'Your appointment at {{BUSINESS_NAME}} has been cancelled',
- 'html_content': self.get_appointment_cancelled_html(),
- 'text_content': self.get_appointment_cancelled_text(),
- },
- {
- 'name': 'Thank You - Appointment Complete',
- 'description': 'Sent after an appointment is completed',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': 'Thank you for visiting {{BUSINESS_NAME}}!',
- 'html_content': self.get_thank_you_html(),
- 'text_content': self.get_thank_you_text(),
- },
-
- # ========== CUSTOMER ONBOARDING ==========
- {
- 'name': 'Welcome New Customer',
- 'description': 'Welcome email for new customer accounts',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': 'Welcome to {{BUSINESS_NAME}}!',
- 'html_content': self.get_welcome_customer_html(),
- 'text_content': self.get_welcome_customer_text(),
- },
-
- # ========== TICKET NOTIFICATIONS ==========
- {
- 'name': 'Ticket Assigned',
- 'description': 'Notification when a ticket is assigned to a staff member',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': '[Ticket #{{TICKET_ID}}] You have been assigned: {{TICKET_SUBJECT}}',
- 'html_content': self.get_ticket_assigned_html(),
- 'text_content': self.get_ticket_assigned_text(),
- },
- {
- 'name': 'Ticket Status Changed',
- 'description': 'Notification when ticket status changes',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': '[Ticket #{{TICKET_ID}}] Status updated: {{TICKET_STATUS}}',
- 'html_content': self.get_ticket_status_changed_html(),
- 'text_content': self.get_ticket_status_changed_text(),
- },
- {
- 'name': 'Ticket Reply - Staff Notification',
- 'description': 'Notification to staff when customer replies to ticket',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': '[Ticket #{{TICKET_ID}}] New reply from customer: {{TICKET_SUBJECT}}',
- 'html_content': self.get_ticket_reply_staff_html(),
- 'text_content': self.get_ticket_reply_staff_text(),
- },
- {
- 'name': 'Ticket Reply - Customer Notification',
- 'description': 'Notification to customer when staff replies to ticket',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': '[Ticket #{{TICKET_ID}}] {{BUSINESS_NAME}} has responded to your request',
- 'html_content': self.get_ticket_reply_customer_html(),
- 'text_content': self.get_ticket_reply_customer_text(),
- },
- {
- 'name': 'Ticket Resolved',
- 'description': 'Notification when a ticket is resolved/closed',
- 'category': 'NOTIFICATION',
- 'scope': 'BUSINESS',
- 'subject': '[Ticket #{{TICKET_ID}}] Your request has been resolved',
- 'html_content': self.get_ticket_resolved_html(),
- 'text_content': self.get_ticket_resolved_text(),
- },
- ]
-
- # ========== HTML TEMPLATES ==========
-
- def get_email_wrapper_start(self, title=''):
- return f'''
-
-
-
-
- {title}
-
-
-
-
-
-'''
-
- def get_email_wrapper_end(self):
- return '''
-
-
-
-
-'''
-
- def get_appointment_confirmation_html(self):
- return self.get_email_wrapper_start('Appointment Confirmation') + '''
- Your Appointment is Confirmed!
-
- Hi {{CUSTOMER_NAME}},
-
-
- Great news! Your appointment at {{BUSINESS_NAME}} has been confirmed.
- We're looking forward to seeing you!
-
-
-
-
- 📅 Date:
- {{APPOINTMENT_DATE}}
-
-
- 🕐 Time:
- {{APPOINTMENT_TIME}}
-
-
- 💼 Service:
- {{APPOINTMENT_SERVICE}}
-
-
-
-
- Need to make changes?
- If you need to reschedule or cancel, please contact us as soon as possible.
-
-
- See you soon!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_appointment_confirmation_text(self):
- return '''Your Appointment is Confirmed!
-
-Hi {{CUSTOMER_NAME}},
-
-Great news! Your appointment at {{BUSINESS_NAME}} has been confirmed.
-
-APPOINTMENT DETAILS
--------------------
-Date: {{APPOINTMENT_DATE}}
-Time: {{APPOINTMENT_TIME}}
-Service: {{APPOINTMENT_SERVICE}}
-
-Need to make changes?
-If you need to reschedule or cancel, please contact us as soon as possible.
-
-See you soon!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- def get_appointment_reminder_html(self, time_before):
- return self.get_email_wrapper_start('Appointment Reminder') + f'''
- Reminder: Your Appointment is Coming Up!
-
- Hi {{{{CUSTOMER_NAME}}}},
-
-
- This is a friendly reminder that your appointment at {{{{BUSINESS_NAME}}}}
- is in {time_before} .
-
-
-
-
- 📅 Date:
- {{{{APPOINTMENT_DATE}}}}
-
-
- 🕐 Time:
- {{{{APPOINTMENT_TIME}}}}
-
-
- 💼 Service:
- {{{{APPOINTMENT_SERVICE}}}}
-
-
-
-
- We recommend arriving 5-10 minutes early to ensure a smooth check-in.
-
-
-
- Need to reschedule?
- Please contact us as soon as possible if you need to make any changes.
-
-
- See you soon!
- The {{{{BUSINESS_NAME}}}} Team
-''' + self.get_email_wrapper_end()
-
- def get_appointment_reminder_text(self, time_before):
- return f'''Reminder: Your Appointment is Coming Up!
-
-Hi {{{{CUSTOMER_NAME}}}},
-
-This is a friendly reminder that your appointment at {{{{BUSINESS_NAME}}}} is in {time_before}.
-
-APPOINTMENT DETAILS
--------------------
-Date: {{{{APPOINTMENT_DATE}}}}
-Time: {{{{APPOINTMENT_TIME}}}}
-Service: {{{{APPOINTMENT_SERVICE}}}}
-
-We recommend arriving 5-10 minutes early to ensure a smooth check-in.
-
-Need to reschedule?
-Please contact us as soon as possible if you need to make any changes.
-
-See you soon!
-The {{{{BUSINESS_NAME}}}} Team
-
----
-{{{{BUSINESS_NAME}}}}
-{{{{BUSINESS_EMAIL}}}}
-{{{{BUSINESS_PHONE}}}}
-'''
-
- def get_appointment_rescheduled_html(self):
- return self.get_email_wrapper_start('Appointment Rescheduled') + '''
- Your Appointment Has Been Rescheduled
-
- Hi {{CUSTOMER_NAME}},
-
-
- Your appointment at {{BUSINESS_NAME}} has been rescheduled.
- Please note the new date and time below.
-
-
-
-
- 📅 New Date:
- {{APPOINTMENT_DATE}}
-
-
- 🕐 New Time:
- {{APPOINTMENT_TIME}}
-
-
- 💼 Service:
- {{APPOINTMENT_SERVICE}}
-
-
-
-
- If this new time doesn't work for you, please contact us to find an alternative.
-
-
- Thank you for your understanding!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_appointment_rescheduled_text(self):
- return '''Your Appointment Has Been Rescheduled
-
-Hi {{CUSTOMER_NAME}},
-
-Your appointment at {{BUSINESS_NAME}} has been rescheduled.
-
-NEW APPOINTMENT DETAILS
------------------------
-Date: {{APPOINTMENT_DATE}}
-Time: {{APPOINTMENT_TIME}}
-Service: {{APPOINTMENT_SERVICE}}
-
-If this new time doesn't work for you, please contact us to find an alternative.
-
-Thank you for your understanding!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- def get_appointment_cancelled_html(self):
- return self.get_email_wrapper_start('Appointment Cancelled') + '''
- Your Appointment Has Been Cancelled
-
- Hi {{CUSTOMER_NAME}},
-
-
- We're writing to confirm that your appointment at {{BUSINESS_NAME}}
- has been cancelled.
-
-
-
-
- Cancelled Appointment:
- {{APPOINTMENT_DATE}} at {{APPOINTMENT_TIME}}
- Service: {{APPOINTMENT_SERVICE}}
-
-
-
-
- We'd love to see you! Would you like to book a new appointment?
- Visit our booking page or give us a call.
-
-
- Thank you!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_appointment_cancelled_text(self):
- return '''Your Appointment Has Been Cancelled
-
-Hi {{CUSTOMER_NAME}},
-
-We're writing to confirm that your appointment at {{BUSINESS_NAME}} has been cancelled.
-
-CANCELLED APPOINTMENT
----------------------
-Date: {{APPOINTMENT_DATE}}
-Time: {{APPOINTMENT_TIME}}
-Service: {{APPOINTMENT_SERVICE}}
-
-We'd love to see you! Would you like to book a new appointment?
-Visit our booking page or give us a call.
-
-Thank you!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- def get_thank_you_html(self):
- return self.get_email_wrapper_start('Thank You') + '''
- Thank You for Visiting!
-
- Hi {{CUSTOMER_NAME}},
-
-
- Thank you for choosing {{BUSINESS_NAME}} !
- We hope you had a wonderful experience with us.
-
-
-
-
- ⭐ We'd Love Your Feedback! ⭐
-
-
- Your opinion helps us improve and helps others find great services.
-
-
-
-
- Ready to book your next appointment? We're here whenever you need us!
-
-
- See you again soon!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_thank_you_text(self):
- return '''Thank You for Visiting!
-
-Hi {{CUSTOMER_NAME}},
-
-Thank you for choosing {{BUSINESS_NAME}}! We hope you had a wonderful experience with us.
-
-We'd Love Your Feedback!
-Your opinion helps us improve and helps others find great services.
-
-Ready to book your next appointment? We're here whenever you need us!
-
-See you again soon!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- def get_welcome_customer_html(self):
- # TODO: Implement full customer onboarding email flow
- # This should include: account setup, booking instructions, loyalty program info
- return self.get_email_wrapper_start('Welcome') + '''
- Welcome to {{BUSINESS_NAME}}!
-
- Hi {{CUSTOMER_NAME}},
-
-
- Welcome! We're thrilled to have you join our community at {{BUSINESS_NAME}} .
-
-
-
-
- 🎉 Your account is all set up! 🎉
-
-
-
- Here's what you can do:
-
- Book appointments online anytime
- View and manage your upcoming appointments
- Update your contact information and preferences
-
-
-
- Ready to book your first appointment?
- We can't wait to see you!
-
-
- Best regards,
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_welcome_customer_text(self):
- # TODO: Implement full customer onboarding email flow
- return '''Welcome to {{BUSINESS_NAME}}!
-
-Hi {{CUSTOMER_NAME}},
-
-Welcome! We're thrilled to have you join our community at {{BUSINESS_NAME}}.
-
-Your account is all set up!
-
-Here's what you can do:
-- Book appointments online anytime
-- View and manage your upcoming appointments
-- Update your contact information and preferences
-
-Ready to book your first appointment?
-We can't wait to see you!
-
-Best regards,
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- # ========== TICKET NOTIFICATION TEMPLATES ==========
-
- def get_ticket_assigned_html(self):
- return self.get_email_wrapper_start('Ticket Assigned') + '''
- New Ticket Assigned to You
-
- Hi {{ASSIGNEE_NAME}},
-
-
- A ticket has been assigned to you and requires your attention.
-
-
-
-
- 🎫 Ticket:
- #{{TICKET_ID}}
-
-
- 📋 Subject:
- {{TICKET_SUBJECT}}
-
-
- ⚡ Priority:
- {{TICKET_PRIORITY}}
-
-
- 👤 From:
- {{TICKET_CUSTOMER_NAME}}
-
-
-
-
-
Message:
-
{{TICKET_MESSAGE}}
-
-
-
- View Ticket
-
-
- Please respond as soon as possible.
- {{BUSINESS_NAME}}
-''' + self.get_email_wrapper_end()
-
- def get_ticket_assigned_text(self):
- return '''New Ticket Assigned to You
-
-Hi {{ASSIGNEE_NAME}},
-
-A ticket has been assigned to you and requires your attention.
-
-TICKET DETAILS
---------------
-Ticket: #{{TICKET_ID}}
-Subject: {{TICKET_SUBJECT}}
-Priority: {{TICKET_PRIORITY}}
-From: {{TICKET_CUSTOMER_NAME}}
-
-Message:
-{{TICKET_MESSAGE}}
-
-View ticket: {{TICKET_URL}}
-
-Please respond as soon as possible.
-
----
-{{BUSINESS_NAME}}
-'''
-
- def get_ticket_status_changed_html(self):
- return self.get_email_wrapper_start('Ticket Status Updated') + '''
- Ticket Status Updated
-
- Hi {{RECIPIENT_NAME}},
-
-
- The status of ticket #{{TICKET_ID}} has been updated.
-
-
-
-
- 🎫 Ticket:
- #{{TICKET_ID}}
-
-
- 📋 Subject:
- {{TICKET_SUBJECT}}
-
-
- 📊 New Status:
- {{TICKET_STATUS}}
-
-
-
-
- View Ticket
-
-
- {{BUSINESS_NAME}}
-''' + self.get_email_wrapper_end()
-
- def get_ticket_status_changed_text(self):
- return '''Ticket Status Updated
-
-Hi {{RECIPIENT_NAME}},
-
-The status of ticket #{{TICKET_ID}} has been updated.
-
-TICKET DETAILS
---------------
-Ticket: #{{TICKET_ID}}
-Subject: {{TICKET_SUBJECT}}
-New Status: {{TICKET_STATUS}}
-
-View ticket: {{TICKET_URL}}
-
----
-{{BUSINESS_NAME}}
-'''
-
- def get_ticket_reply_staff_html(self):
- return self.get_email_wrapper_start('New Customer Reply') + '''
- New Reply on Ticket #{{TICKET_ID}}
-
- Hi {{ASSIGNEE_NAME}},
-
-
- {{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}} .
-
-
-
-
- Subject: {{TICKET_SUBJECT}}
-
-
{{REPLY_MESSAGE}}
-
-
-
- View & Reply
-
-
- {{BUSINESS_NAME}}
-''' + self.get_email_wrapper_end()
-
- def get_ticket_reply_staff_text(self):
- return '''New Reply on Ticket #{{TICKET_ID}}
-
-Hi {{ASSIGNEE_NAME}},
-
-{{TICKET_CUSTOMER_NAME}} has replied to ticket #{{TICKET_ID}}.
-
-Subject: {{TICKET_SUBJECT}}
-
-Reply:
-{{REPLY_MESSAGE}}
-
-View & reply: {{TICKET_URL}}
-
----
-{{BUSINESS_NAME}}
-'''
-
- def get_ticket_reply_customer_html(self):
- return self.get_email_wrapper_start('Response to Your Request') + '''
- We've Responded to Your Request
-
- Hi {{CUSTOMER_NAME}},
-
-
- We've replied to your support request.
-
-
-
-
- 🎫 Ticket:
- #{{TICKET_ID}}
-
-
- 📋 Subject:
- {{TICKET_SUBJECT}}
-
-
-
-
-
Our Response:
-
{{REPLY_MESSAGE}}
-
-
-
- Need to reply?
- Simply reply to this email or click the button below.
-
-
-
- View Full Conversation
-
-
- Thank you for contacting us!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_ticket_reply_customer_text(self):
- return '''We've Responded to Your Request
-
-Hi {{CUSTOMER_NAME}},
-
-We've replied to your support request.
-
-TICKET DETAILS
---------------
-Ticket: #{{TICKET_ID}}
-Subject: {{TICKET_SUBJECT}}
-
-Our Response:
-{{REPLY_MESSAGE}}
-
-Need to reply?
-Simply reply to this email or visit: {{TICKET_URL}}
-
-Thank you for contacting us!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
-
- def get_ticket_resolved_html(self):
- return self.get_email_wrapper_start('Ticket Resolved') + '''
- Your Request Has Been Resolved
-
- Hi {{CUSTOMER_NAME}},
-
-
- Great news! Your support request has been resolved.
-
-
-
-
- ✅ Ticket #{{TICKET_ID}} - Resolved
-
-
-
-
-
- Subject: {{TICKET_SUBJECT}}
-
-
- Resolution: {{RESOLUTION_MESSAGE}}
-
-
-
-
- Not satisfied with the resolution?
- You can reopen this ticket by replying to this email within the next 7 days.
-
-
-
- View Ticket History
-
-
- Thank you for your patience!
- The {{BUSINESS_NAME}} Team
-''' + self.get_email_wrapper_end()
-
- def get_ticket_resolved_text(self):
- return '''Your Request Has Been Resolved
-
-Hi {{CUSTOMER_NAME}},
-
-Great news! Your support request has been resolved.
-
-Ticket #{{TICKET_ID}} - RESOLVED
-
-Subject: {{TICKET_SUBJECT}}
-Resolution: {{RESOLUTION_MESSAGE}}
-
-Not satisfied with the resolution?
-You can reopen this ticket by replying to this email within the next 7 days.
-
-View ticket history: {{TICKET_URL}}
-
-Thank you for your patience!
-The {{BUSINESS_NAME}} Team
-
----
-{{BUSINESS_NAME}}
-{{BUSINESS_EMAIL}}
-{{BUSINESS_PHONE}}
-'''
diff --git a/smoothschedule/tickets/migrations/0013_delete_ticketemailsettings.py b/smoothschedule/tickets/migrations/0013_delete_ticketemailsettings.py
new file mode 100644
index 0000000..fdaee80
--- /dev/null
+++ b/smoothschedule/tickets/migrations/0013_delete_ticketemailsettings.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.2.8 on 2025-12-02 02:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tickets', '0012_migrate_email_settings_to_addresses'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='TicketEmailSettings',
+ ),
+ ]