= ({ isOpen, onClose }
'ENTERPRISE': 'Enterprise',
};
const plan = subscriptionPlans.find(p =>
- p.business_tier === tierNameMap[tier] || p.business_tier === tier
+ p.name === tierNameMap[tier] || p.name === tier
);
if (plan) {
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
diff --git a/frontend/src/pages/settings/BillingSettings.tsx b/frontend/src/pages/settings/BillingSettings.tsx
index c649cc6..effcb0b 100644
--- a/frontend/src/pages/settings/BillingSettings.tsx
+++ b/frontend/src/pages/settings/BillingSettings.tsx
@@ -518,7 +518,7 @@ const BillingSettings: React.FC = () => {
) : (
{availablePlans.map((plan) => {
- const isCurrentPlan = plan.business_tier === currentTier;
+ const isCurrentPlan = plan.name === currentTier;
const isUpgrade = (plan.price_monthly || 0) > (currentPlan?.price_monthly || 0);
return (
diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py
index aa88aa0..9617b99 100644
--- a/smoothschedule/config/settings/multitenancy.py
+++ b/smoothschedule/config/settings/multitenancy.py
@@ -51,7 +51,7 @@ SHARED_APPS = [
'djstripe', # Stripe integration
# Commerce Domain (shared for platform support)
- 'smoothschedule.commerce.billing', # Billing, subscriptions, entitlements
+ 'smoothschedule.billing', # Billing, subscriptions, entitlements
'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access
# Communication Domain (shared)
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index 78c1895..b797f48 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -100,7 +100,7 @@ urlpatterns += [
# Messaging API (broadcast messages)
path("messages/", include("smoothschedule.communication.messaging.urls")),
# Billing API
- path("", include("smoothschedule.commerce.billing.api.urls", namespace="billing")),
+ path("", include("smoothschedule.billing.api.urls", namespace="billing")),
# Platform API
path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")),
# OAuth Email Integration API
diff --git a/smoothschedule/smoothschedule/commerce/billing/__init__.py b/smoothschedule/smoothschedule/billing/__init__.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/__init__.py
rename to smoothschedule/smoothschedule/billing/__init__.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/admin.py b/smoothschedule/smoothschedule/billing/admin.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/admin.py
rename to smoothschedule/smoothschedule/billing/admin.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/api/__init__.py b/smoothschedule/smoothschedule/billing/api/__init__.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/api/__init__.py
rename to smoothschedule/smoothschedule/billing/api/__init__.py
diff --git a/smoothschedule/smoothschedule/billing/api/serializers.py b/smoothschedule/smoothschedule/billing/api/serializers.py
new file mode 100644
index 0000000..9186217
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/api/serializers.py
@@ -0,0 +1,519 @@
+"""
+DRF serializers for billing API endpoints.
+"""
+
+from rest_framework import serializers
+
+from smoothschedule.billing.models import AddOnProduct
+from smoothschedule.billing.models import Feature
+from smoothschedule.billing.models import Invoice
+from smoothschedule.billing.models import InvoiceLine
+from smoothschedule.billing.models import Plan
+from smoothschedule.billing.models import PlanFeature
+from smoothschedule.billing.models import PlanVersion
+from smoothschedule.billing.models import Subscription
+from smoothschedule.billing.models import SubscriptionAddOn
+
+
+class FeatureSerializer(serializers.ModelSerializer):
+ """Serializer for Feature model."""
+
+ class Meta:
+ model = Feature
+ fields = ["id", "code", "name", "description", "feature_type"]
+
+
+class PlanSerializer(serializers.ModelSerializer):
+ """Serializer for Plan model."""
+
+ class Meta:
+ model = Plan
+ fields = ["id", "code", "name", "description", "display_order", "is_active"]
+
+
+class PlanFeatureSerializer(serializers.ModelSerializer):
+ """Serializer for PlanFeature model."""
+
+ feature = FeatureSerializer(read_only=True)
+ value = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PlanFeature
+ fields = ["id", "feature", "bool_value", "int_value", "value"]
+
+ def get_value(self, obj):
+ """Return the effective value based on feature type."""
+ return obj.get_value()
+
+
+class PlanVersionSerializer(serializers.ModelSerializer):
+ """Serializer for PlanVersion model."""
+
+ plan = PlanSerializer(read_only=True)
+ features = PlanFeatureSerializer(many=True, read_only=True)
+ is_available = serializers.BooleanField(read_only=True)
+
+ class Meta:
+ model = PlanVersion
+ fields = [
+ "id",
+ "plan",
+ "version",
+ "name",
+ "is_public",
+ "is_legacy",
+ "starts_at",
+ "ends_at",
+ "price_monthly_cents",
+ "price_yearly_cents",
+ # Transaction fees
+ "transaction_fee_percent",
+ "transaction_fee_fixed_cents",
+ # Trial
+ "trial_days",
+ # Communication pricing (costs when feature is enabled)
+ "sms_price_per_message_cents",
+ "masked_calling_price_per_minute_cents",
+ "proxy_number_monthly_fee_cents",
+ # Credit settings
+ "default_auto_reload_enabled",
+ "default_auto_reload_threshold_cents",
+ "default_auto_reload_amount_cents",
+ # Display settings
+ "is_most_popular",
+ "show_price",
+ "marketing_features",
+ # Stripe
+ "stripe_product_id",
+ "stripe_price_id_monthly",
+ "stripe_price_id_yearly",
+ "is_available",
+ # Features (entitlements via PlanFeature M2M)
+ "features",
+ "created_at",
+ ]
+
+
+class PlanVersionSummarySerializer(serializers.ModelSerializer):
+ """Lightweight serializer for PlanVersion without features."""
+
+ plan_code = serializers.CharField(source="plan.code", read_only=True)
+ plan_name = serializers.CharField(source="plan.name", read_only=True)
+
+ class Meta:
+ model = PlanVersion
+ fields = [
+ "id",
+ "plan_code",
+ "plan_name",
+ "version",
+ "name",
+ "is_legacy",
+ "price_monthly_cents",
+ "price_yearly_cents",
+ ]
+
+
+class AddOnProductSerializer(serializers.ModelSerializer):
+ """Serializer for AddOnProduct model."""
+
+ class Meta:
+ model = AddOnProduct
+ fields = [
+ "id",
+ "code",
+ "name",
+ "description",
+ "price_monthly_cents",
+ "price_one_time_cents",
+ "is_active",
+ ]
+
+
+class SubscriptionAddOnSerializer(serializers.ModelSerializer):
+ """Serializer for SubscriptionAddOn model."""
+
+ addon = AddOnProductSerializer(read_only=True)
+ is_active = serializers.BooleanField(read_only=True)
+
+ class Meta:
+ model = SubscriptionAddOn
+ fields = [
+ "id",
+ "addon",
+ "status",
+ "activated_at",
+ "expires_at",
+ "is_active",
+ ]
+
+
+class SubscriptionSerializer(serializers.ModelSerializer):
+ """Serializer for Subscription model."""
+
+ plan_version = PlanVersionSummarySerializer(read_only=True)
+ addons = SubscriptionAddOnSerializer(many=True, read_only=True)
+ is_active = serializers.BooleanField(read_only=True)
+
+ class Meta:
+ model = Subscription
+ fields = [
+ "id",
+ "plan_version",
+ "status",
+ "is_active",
+ "started_at",
+ "current_period_start",
+ "current_period_end",
+ "trial_ends_at",
+ "canceled_at",
+ "addons",
+ "created_at",
+ "updated_at",
+ ]
+
+
+class InvoiceLineSerializer(serializers.ModelSerializer):
+ """Serializer for InvoiceLine model."""
+
+ class Meta:
+ model = InvoiceLine
+ fields = [
+ "id",
+ "line_type",
+ "description",
+ "quantity",
+ "unit_amount",
+ "subtotal_amount",
+ "tax_amount",
+ "total_amount",
+ "feature_code",
+ "metadata",
+ "created_at",
+ ]
+
+
+class InvoiceSerializer(serializers.ModelSerializer):
+ """Serializer for Invoice model."""
+
+ lines = InvoiceLineSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Invoice
+ fields = [
+ "id",
+ "period_start",
+ "period_end",
+ "currency",
+ "subtotal_amount",
+ "discount_amount",
+ "tax_amount",
+ "total_amount",
+ "status",
+ "plan_code_at_billing",
+ "plan_name_at_billing",
+ "stripe_invoice_id",
+ "created_at",
+ "paid_at",
+ "lines",
+ ]
+
+
+class InvoiceListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for invoice list."""
+
+ class Meta:
+ model = Invoice
+ fields = [
+ "id",
+ "period_start",
+ "period_end",
+ "total_amount",
+ "status",
+ "plan_name_at_billing",
+ "created_at",
+ "paid_at",
+ ]
+
+
+# =============================================================================
+# Admin Serializers (for platform admin management)
+# =============================================================================
+
+
+class FeatureCreateSerializer(serializers.ModelSerializer):
+ """Serializer for creating/updating Features."""
+
+ class Meta:
+ model = Feature
+ fields = ["code", "name", "description", "feature_type"]
+
+
+class PlanCreateSerializer(serializers.ModelSerializer):
+ """Serializer for creating/updating Plans."""
+
+ class Meta:
+ model = Plan
+ fields = [
+ "code",
+ "name",
+ "description",
+ "display_order",
+ "is_active",
+ "max_pages",
+ "allow_custom_domains",
+ "max_custom_domains",
+ ]
+
+
+class PlanFeatureWriteSerializer(serializers.Serializer):
+ """Serializer for writing plan features."""
+
+ feature_code = serializers.CharField()
+ bool_value = serializers.BooleanField(required=False, allow_null=True)
+ int_value = serializers.IntegerField(required=False, allow_null=True)
+
+
+class PlanVersionCreateSerializer(serializers.ModelSerializer):
+ """Serializer for creating a new PlanVersion.
+
+ Note: Features/permissions/limits are managed via PlanFeature M2M, not direct fields.
+ Pass them in the 'features' array with feature_code and bool_value/int_value.
+ """
+
+ plan_code = serializers.CharField(write_only=True)
+ features = PlanFeatureWriteSerializer(many=True, required=False, write_only=True)
+
+ class Meta:
+ model = PlanVersion
+ fields = [
+ "plan_code",
+ "name",
+ "is_public",
+ "starts_at",
+ "ends_at",
+ "price_monthly_cents",
+ "price_yearly_cents",
+ # Transaction fees
+ "transaction_fee_percent",
+ "transaction_fee_fixed_cents",
+ # Trial
+ "trial_days",
+ # Communication pricing (costs when feature is enabled)
+ "sms_price_per_message_cents",
+ "masked_calling_price_per_minute_cents",
+ "proxy_number_monthly_fee_cents",
+ # Credit settings
+ "default_auto_reload_enabled",
+ "default_auto_reload_threshold_cents",
+ "default_auto_reload_amount_cents",
+ # Display settings
+ "is_most_popular",
+ "show_price",
+ "marketing_features",
+ # Stripe
+ "stripe_product_id",
+ "stripe_price_id_monthly",
+ "stripe_price_id_yearly",
+ # Features (M2M via PlanFeature)
+ "features",
+ ]
+
+ def create(self, validated_data):
+ plan_code = validated_data.pop("plan_code")
+ features_data = validated_data.pop("features", [])
+
+ try:
+ plan = Plan.objects.get(code=plan_code)
+ except Plan.DoesNotExist:
+ raise serializers.ValidationError({"plan_code": f"Plan '{plan_code}' not found"})
+
+ # Determine next version number
+ latest_version = plan.versions.order_by("-version").first()
+ next_version = (latest_version.version + 1) if latest_version else 1
+
+ # Create the version
+ plan_version = PlanVersion.objects.create(
+ plan=plan,
+ version=next_version,
+ **validated_data,
+ )
+
+ # Create plan features
+ for feature_data in features_data:
+ try:
+ feature = Feature.objects.get(code=feature_data["feature_code"])
+ except Feature.DoesNotExist:
+ continue # Skip unknown features
+
+ PlanFeature.objects.create(
+ plan_version=plan_version,
+ feature=feature,
+ bool_value=feature_data.get("bool_value"),
+ int_value=feature_data.get("int_value"),
+ )
+
+ return plan_version
+
+
+class PlanVersionUpdateSerializer(serializers.ModelSerializer):
+ """Serializer for updating a PlanVersion.
+
+ If the version has active subscribers, this will create a new version
+ instead of updating in place (grandfathering).
+
+ Note: Features/permissions/limits are managed via PlanFeature M2M, not direct fields.
+ """
+
+ features = PlanFeatureWriteSerializer(many=True, required=False, write_only=True)
+
+ class Meta:
+ model = PlanVersion
+ fields = [
+ "name",
+ "is_public",
+ "is_legacy",
+ "starts_at",
+ "ends_at",
+ "price_monthly_cents",
+ "price_yearly_cents",
+ # Transaction fees
+ "transaction_fee_percent",
+ "transaction_fee_fixed_cents",
+ # Trial
+ "trial_days",
+ # Communication pricing (costs when feature is enabled)
+ "sms_price_per_message_cents",
+ "masked_calling_price_per_minute_cents",
+ "proxy_number_monthly_fee_cents",
+ # Credit settings
+ "default_auto_reload_enabled",
+ "default_auto_reload_threshold_cents",
+ "default_auto_reload_amount_cents",
+ # Display settings
+ "is_most_popular",
+ "show_price",
+ "marketing_features",
+ # Stripe
+ "stripe_product_id",
+ "stripe_price_id_monthly",
+ "stripe_price_id_yearly",
+ # Features (M2M via PlanFeature)
+ "features",
+ ]
+
+
+class PlanVersionDetailSerializer(serializers.ModelSerializer):
+ """Detailed serializer for PlanVersion with subscriber count.
+
+ Note: Features/permissions/limits are in the 'features' array (PlanFeature M2M).
+ """
+
+ plan = PlanSerializer(read_only=True)
+ features = PlanFeatureSerializer(many=True, read_only=True)
+ is_available = serializers.BooleanField(read_only=True)
+ subscriber_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = PlanVersion
+ fields = [
+ "id",
+ "plan",
+ "version",
+ "name",
+ "is_public",
+ "is_legacy",
+ "starts_at",
+ "ends_at",
+ "price_monthly_cents",
+ "price_yearly_cents",
+ # Transaction fees
+ "transaction_fee_percent",
+ "transaction_fee_fixed_cents",
+ # Trial
+ "trial_days",
+ # Communication pricing (costs when feature is enabled)
+ "sms_price_per_message_cents",
+ "masked_calling_price_per_minute_cents",
+ "proxy_number_monthly_fee_cents",
+ # Credit settings
+ "default_auto_reload_enabled",
+ "default_auto_reload_threshold_cents",
+ "default_auto_reload_amount_cents",
+ # Display settings
+ "is_most_popular",
+ "show_price",
+ "marketing_features",
+ # Stripe
+ "stripe_product_id",
+ "stripe_price_id_monthly",
+ "stripe_price_id_yearly",
+ "is_available",
+ # Features (M2M via PlanFeature)
+ "features",
+ "subscriber_count",
+ "created_at",
+ ]
+
+ def get_subscriber_count(self, obj):
+ """Count active subscribers on this version."""
+ return Subscription.objects.filter(
+ plan_version=obj,
+ status__in=["active", "trial"],
+ ).count()
+
+
+class AddOnProductCreateSerializer(serializers.ModelSerializer):
+ """Serializer for creating/updating AddOnProducts."""
+
+ class Meta:
+ model = AddOnProduct
+ fields = [
+ "code",
+ "name",
+ "description",
+ "price_monthly_cents",
+ "price_one_time_cents",
+ "stripe_product_id",
+ "stripe_price_id",
+ "is_active",
+ ]
+
+
+class PlanWithVersionsSerializer(serializers.ModelSerializer):
+ """Serializer for Plan with all its versions."""
+
+ versions = PlanVersionDetailSerializer(many=True, read_only=True)
+ active_version = serializers.SerializerMethodField()
+ total_subscribers = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Plan
+ fields = [
+ "id",
+ "code",
+ "name",
+ "description",
+ "display_order",
+ "is_active",
+ "max_pages",
+ "allow_custom_domains",
+ "max_custom_domains",
+ "versions",
+ "active_version",
+ "total_subscribers",
+ ]
+
+ def get_active_version(self, obj):
+ """Get the current active (non-legacy) version."""
+ active = obj.versions.filter(is_public=True, is_legacy=False).first()
+ if active:
+ return PlanVersionDetailSerializer(active).data
+ return None
+
+ def get_total_subscribers(self, obj):
+ """Count total subscribers across all versions."""
+ return Subscription.objects.filter(
+ plan_version__plan=obj,
+ status__in=["active", "trial"],
+ ).count()
diff --git a/smoothschedule/smoothschedule/billing/api/urls.py b/smoothschedule/smoothschedule/billing/api/urls.py
new file mode 100644
index 0000000..fef1f9d
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/api/urls.py
@@ -0,0 +1,46 @@
+"""
+URL routes for billing API endpoints.
+"""
+
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+
+from smoothschedule.billing.api.views import (
+ AddOnCatalogView,
+ CurrentSubscriptionView,
+ EntitlementsView,
+ InvoiceDetailView,
+ InvoiceListView,
+ PlanCatalogView,
+ # Admin ViewSets
+ FeatureViewSet,
+ PlanViewSet,
+ PlanVersionViewSet,
+ AddOnProductViewSet,
+)
+
+app_name = "billing"
+
+# Admin router for platform admin management
+admin_router = DefaultRouter()
+admin_router.register(r"features", FeatureViewSet, basename="admin-feature")
+admin_router.register(r"plans", PlanViewSet, basename="admin-plan")
+admin_router.register(r"plan-versions", PlanVersionViewSet, basename="admin-plan-version")
+admin_router.register(r"addons", AddOnProductViewSet, basename="admin-addon")
+
+urlpatterns = [
+ # /api/me/ endpoints (current user/business context)
+ path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
+ path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
+ # /api/billing/ endpoints (public catalog)
+ path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
+ path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
+ path("billing/invoices/", InvoiceListView.as_view(), name="invoice-list"),
+ path(
+ "billing/invoices//",
+ InvoiceDetailView.as_view(),
+ name="invoice-detail",
+ ),
+ # /api/billing/admin/ endpoints (platform admin management)
+ path("billing/admin/", include(admin_router.urls)),
+]
diff --git a/smoothschedule/smoothschedule/billing/api/views.py b/smoothschedule/smoothschedule/billing/api/views.py
new file mode 100644
index 0000000..06fb2fe
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/api/views.py
@@ -0,0 +1,471 @@
+"""
+DRF API views for billing endpoints.
+"""
+
+from django.db import transaction
+from rest_framework import status, viewsets
+from rest_framework.decorators import action
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from smoothschedule.billing.api.serializers import AddOnProductSerializer
+from smoothschedule.billing.api.serializers import InvoiceListSerializer
+from smoothschedule.billing.api.serializers import InvoiceSerializer
+from smoothschedule.billing.api.serializers import PlanVersionSerializer
+from smoothschedule.billing.api.serializers import SubscriptionSerializer
+from smoothschedule.billing.api.serializers import (
+ FeatureSerializer,
+ FeatureCreateSerializer,
+ PlanSerializer,
+ PlanCreateSerializer,
+ PlanWithVersionsSerializer,
+ PlanVersionCreateSerializer,
+ PlanVersionUpdateSerializer,
+ PlanVersionDetailSerializer,
+ AddOnProductCreateSerializer,
+ PlanFeatureWriteSerializer,
+)
+from smoothschedule.billing.models import (
+ AddOnProduct,
+ Feature,
+ Invoice,
+ Plan,
+ PlanFeature,
+ PlanVersion,
+ Subscription,
+)
+from smoothschedule.billing.services.entitlements import EntitlementService
+from smoothschedule.platform.admin.permissions import IsPlatformAdmin
+
+
+class EntitlementsView(APIView):
+ """
+ GET /api/me/entitlements/
+
+ Returns the current business's effective entitlements.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response({})
+
+ entitlements = EntitlementService.get_effective_entitlements(tenant)
+ return Response(entitlements)
+
+
+class CurrentSubscriptionView(APIView):
+ """
+ GET /api/me/subscription/
+
+ Returns the current business's subscription with plan version details.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response(
+ {"detail": "No tenant context"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ subscription = getattr(tenant, "billing_subscription", None)
+ if not subscription:
+ return Response(
+ {"detail": "No subscription found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ serializer = SubscriptionSerializer(subscription)
+ return Response(serializer.data)
+
+
+class PlanCatalogView(APIView):
+ """
+ GET /api/billing/plans/
+
+ Returns public, non-legacy plan versions (the plan catalog).
+ """
+
+ # This endpoint is public - no authentication required
+ # Allows visitors to see pricing before signup
+
+ def get(self, request):
+ # Filter for public, non-legacy plans
+ plan_versions = (
+ PlanVersion.objects.filter(is_public=True, is_legacy=False)
+ .select_related("plan")
+ .prefetch_related("features__feature")
+ .order_by("plan__display_order", "plan__name", "-version")
+ )
+
+ # Filter by availability window (is_available property)
+ available_versions = [pv for pv in plan_versions if pv.is_available]
+
+ serializer = PlanVersionSerializer(available_versions, many=True)
+ return Response(serializer.data)
+
+
+class AddOnCatalogView(APIView):
+ """
+ GET /api/billing/addons/
+
+ Returns available add-on products.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ addons = AddOnProduct.objects.filter(is_active=True)
+ serializer = AddOnProductSerializer(addons, many=True)
+ return Response(serializer.data)
+
+
+class InvoiceListView(APIView):
+ """
+ GET /api/billing/invoices/
+
+ Returns paginated invoice list for the current business.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response(
+ {"detail": "No tenant context"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Tenant-isolated query
+ invoices = Invoice.objects.filter(business=tenant).order_by("-created_at")
+
+ # Simple pagination
+ page_size = int(request.query_params.get("page_size", 20))
+ page = int(request.query_params.get("page", 1))
+ offset = (page - 1) * page_size
+
+ total_count = invoices.count()
+ invoices_page = invoices[offset : offset + page_size]
+
+ serializer = InvoiceListSerializer(invoices_page, many=True)
+ return Response(
+ {
+ "count": total_count,
+ "page": page,
+ "page_size": page_size,
+ "results": serializer.data,
+ }
+ )
+
+
+class InvoiceDetailView(APIView):
+ """
+ GET /api/billing/invoices/{id}/
+
+ Returns invoice detail with line items.
+ """
+
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, invoice_id):
+ tenant = getattr(request.user, "tenant", None)
+ if not tenant:
+ return Response(
+ {"detail": "No tenant context"},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ # Tenant-isolated query - cannot see other tenant's invoices
+ try:
+ invoice = Invoice.objects.prefetch_related("lines").get(
+ business=tenant, id=invoice_id
+ )
+ except Invoice.DoesNotExist:
+ return Response(
+ {"detail": "Invoice not found"},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+ serializer = InvoiceSerializer(invoice)
+ return Response(serializer.data)
+
+
+# =============================================================================
+# Admin ViewSets (for platform admin management)
+# =============================================================================
+
+
+class FeatureViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing Features.
+ Platform admins only.
+
+ Features are the building blocks that can be assigned to plans.
+ """
+
+ queryset = Feature.objects.all().order_by("name")
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def get_serializer_class(self):
+ if self.action in ["create", "update", "partial_update"]:
+ return FeatureCreateSerializer
+ return FeatureSerializer
+
+
+class PlanViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing Plans.
+ Platform admins only.
+
+ Plans are logical groupings (Free, Starter, Pro, Enterprise).
+ Each plan can have multiple versions for grandfathering.
+ """
+
+ queryset = Plan.objects.all().prefetch_related(
+ "versions", "versions__features", "versions__features__feature"
+ ).order_by("display_order", "name")
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def get_serializer_class(self):
+ if self.action in ["create", "update", "partial_update"]:
+ return PlanCreateSerializer
+ if self.action == "retrieve":
+ return PlanWithVersionsSerializer
+ return PlanSerializer
+
+ def list(self, request, *args, **kwargs):
+ """List all plans with their active versions."""
+ queryset = self.get_queryset()
+ serializer = PlanWithVersionsSerializer(queryset, many=True)
+ return Response(serializer.data)
+
+ @action(detail=True, methods=["post"])
+ def create_version(self, request, pk=None):
+ """
+ Create a new version for this plan.
+ POST /api/billing/admin/plans/{id}/create_version/
+ """
+ plan = self.get_object()
+ serializer = PlanVersionCreateSerializer(
+ data={**request.data, "plan_code": plan.code}
+ )
+ serializer.is_valid(raise_exception=True)
+ version = serializer.save()
+ return Response(
+ PlanVersionDetailSerializer(version).data,
+ status=status.HTTP_201_CREATED,
+ )
+
+
+class PlanVersionViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing PlanVersions.
+ Platform admins only.
+
+ Key behavior:
+ - When updating a version with active subscribers, a new version is
+ created and the old one is marked as legacy (grandfathering).
+ - Versions without subscribers can be edited directly.
+ """
+
+ queryset = PlanVersion.objects.all().select_related("plan").prefetch_related(
+ "features", "features__feature"
+ ).order_by("plan__display_order", "plan__name", "-version")
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def get_serializer_class(self):
+ if self.action == "create":
+ return PlanVersionCreateSerializer
+ if self.action in ["update", "partial_update"]:
+ return PlanVersionUpdateSerializer
+ return PlanVersionDetailSerializer
+
+ def update(self, request, *args, **kwargs):
+ """
+ Update a plan version with grandfathering support.
+
+ If the version has active subscribers:
+ 1. Mark the current version as legacy
+ 2. Create a new version with the updates
+ 3. Return the new version
+
+ If no active subscribers:
+ - Update the version directly
+ """
+ instance = self.get_object()
+ partial = kwargs.pop("partial", False)
+
+ # Check for active subscribers
+ subscriber_count = Subscription.objects.filter(
+ plan_version=instance,
+ status__in=["active", "trial"],
+ ).count()
+
+ if subscriber_count > 0:
+ # Grandfathering: create new version, mark old as legacy
+ return self._create_new_version(instance, request.data)
+
+ # No subscribers - update directly
+ serializer = self.get_serializer(instance, data=request.data, partial=partial)
+ serializer.is_valid(raise_exception=True)
+ self.perform_update(serializer)
+
+ # Handle features if provided
+ features_data = request.data.get("features")
+ if features_data is not None:
+ self._update_features(instance, features_data)
+
+ return Response(PlanVersionDetailSerializer(instance).data)
+
+ def _create_new_version(self, old_version, data):
+ """Create a new version based on the old one with updates.
+
+ Note: Features/permissions/limits are managed via PlanFeature M2M.
+ They are copied from the old version or provided in the 'features' array.
+ """
+ with transaction.atomic():
+ # Mark old version as legacy
+ old_version.is_legacy = True
+ old_version.is_public = False
+ old_version.save()
+
+ # Determine new version number
+ next_version = old_version.plan.versions.count() + 1
+
+ # Create new version with all fields
+ new_version = PlanVersion.objects.create(
+ plan=old_version.plan,
+ version=next_version,
+ name=data.get("name", old_version.name),
+ is_public=data.get("is_public", True),
+ is_legacy=False,
+ starts_at=data.get("starts_at"),
+ ends_at=data.get("ends_at"),
+ # Pricing
+ price_monthly_cents=data.get("price_monthly_cents", old_version.price_monthly_cents),
+ price_yearly_cents=data.get("price_yearly_cents", old_version.price_yearly_cents),
+ # Transaction fees
+ transaction_fee_percent=data.get("transaction_fee_percent", old_version.transaction_fee_percent),
+ transaction_fee_fixed_cents=data.get("transaction_fee_fixed_cents", old_version.transaction_fee_fixed_cents),
+ # Trial
+ trial_days=data.get("trial_days", old_version.trial_days),
+ # Communication pricing (costs when feature is enabled)
+ sms_price_per_message_cents=data.get("sms_price_per_message_cents", old_version.sms_price_per_message_cents),
+ masked_calling_price_per_minute_cents=data.get("masked_calling_price_per_minute_cents", old_version.masked_calling_price_per_minute_cents),
+ proxy_number_monthly_fee_cents=data.get("proxy_number_monthly_fee_cents", old_version.proxy_number_monthly_fee_cents),
+ # Credit settings
+ default_auto_reload_enabled=data.get("default_auto_reload_enabled", old_version.default_auto_reload_enabled),
+ default_auto_reload_threshold_cents=data.get("default_auto_reload_threshold_cents", old_version.default_auto_reload_threshold_cents),
+ default_auto_reload_amount_cents=data.get("default_auto_reload_amount_cents", old_version.default_auto_reload_amount_cents),
+ # Display settings
+ is_most_popular=data.get("is_most_popular", old_version.is_most_popular),
+ show_price=data.get("show_price", old_version.show_price),
+ marketing_features=data.get("marketing_features", old_version.marketing_features),
+ # Stripe
+ stripe_product_id=data.get("stripe_product_id", old_version.stripe_product_id),
+ stripe_price_id_monthly=data.get("stripe_price_id_monthly", old_version.stripe_price_id_monthly),
+ stripe_price_id_yearly=data.get("stripe_price_id_yearly", old_version.stripe_price_id_yearly),
+ )
+
+ # Copy features from old version or use provided features
+ features_data = data.get("features")
+ if features_data is not None:
+ self._create_features(new_version, features_data)
+ else:
+ # Copy features from old version
+ for old_feature in old_version.features.all():
+ PlanFeature.objects.create(
+ plan_version=new_version,
+ feature=old_feature.feature,
+ bool_value=old_feature.bool_value,
+ int_value=old_feature.int_value,
+ )
+
+ return Response(
+ {
+ "message": f"Created new version (v{next_version}) and marked v{old_version.version} as legacy. {Subscription.objects.filter(plan_version=old_version, status__in=['active', 'trial']).count()} subscriber(s) will keep their current plan.",
+ "old_version": PlanVersionDetailSerializer(old_version).data,
+ "new_version": PlanVersionDetailSerializer(new_version).data,
+ },
+ status=status.HTTP_201_CREATED,
+ )
+
+ def _update_features(self, version, features_data):
+ """Update features for a version."""
+ # Clear existing features
+ version.features.all().delete()
+ self._create_features(version, features_data)
+
+ def _create_features(self, version, features_data):
+ """Create features for a version."""
+ for feature_data in features_data:
+ try:
+ feature = Feature.objects.get(code=feature_data["feature_code"])
+ except Feature.DoesNotExist:
+ continue
+
+ PlanFeature.objects.create(
+ plan_version=version,
+ feature=feature,
+ bool_value=feature_data.get("bool_value"),
+ int_value=feature_data.get("int_value"),
+ )
+
+ @action(detail=True, methods=["post"])
+ def mark_legacy(self, request, pk=None):
+ """
+ Mark a version as legacy (hidden from new signups).
+ POST /api/billing/admin/plan-versions/{id}/mark_legacy/
+ """
+ version = self.get_object()
+ version.is_legacy = True
+ version.is_public = False
+ version.save()
+ return Response(PlanVersionDetailSerializer(version).data)
+
+ @action(detail=True, methods=["get"])
+ def subscribers(self, request, pk=None):
+ """
+ Get list of subscribers on this version.
+ GET /api/billing/admin/plan-versions/{id}/subscribers/
+ """
+ version = self.get_object()
+ subscriptions = Subscription.objects.filter(
+ plan_version=version
+ ).select_related("business")
+
+ return Response({
+ "version": version.name,
+ "subscriber_count": subscriptions.count(),
+ "subscribers": [
+ {
+ "business_id": sub.business.id,
+ "business_name": sub.business.name,
+ "status": sub.status,
+ "started_at": sub.started_at,
+ }
+ for sub in subscriptions[:100] # Limit to 100
+ ],
+ })
+
+
+class AddOnProductViewSet(viewsets.ModelViewSet):
+ """
+ ViewSet for managing AddOnProducts.
+ Platform admins only.
+ """
+
+ queryset = AddOnProduct.objects.all().order_by("name")
+ permission_classes = [IsAuthenticated, IsPlatformAdmin]
+
+ def get_serializer_class(self):
+ if self.action in ["create", "update", "partial_update"]:
+ return AddOnProductCreateSerializer
+ return AddOnProductSerializer
diff --git a/smoothschedule/smoothschedule/commerce/billing/apps.py b/smoothschedule/smoothschedule/billing/apps.py
similarity index 79%
rename from smoothschedule/smoothschedule/commerce/billing/apps.py
rename to smoothschedule/smoothschedule/billing/apps.py
index 4a3d71d..213cc58 100644
--- a/smoothschedule/smoothschedule/commerce/billing/apps.py
+++ b/smoothschedule/smoothschedule/billing/apps.py
@@ -3,6 +3,6 @@ from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
- name = "smoothschedule.commerce.billing"
+ name = "smoothschedule.billing"
label = "billing"
verbose_name = "Billing"
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0001_initial_billing_models.py b/smoothschedule/smoothschedule/billing/migrations/0001_initial_billing_models.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/0001_initial_billing_models.py
rename to smoothschedule/smoothschedule/billing/migrations/0001_initial_billing_models.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0002_add_invoice_models.py b/smoothschedule/smoothschedule/billing/migrations/0002_add_invoice_models.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/0002_add_invoice_models.py
rename to smoothschedule/smoothschedule/billing/migrations/0002_add_invoice_models.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py b/smoothschedule/smoothschedule/billing/migrations/0003_seed_initial_plans.py
similarity index 98%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py
rename to smoothschedule/smoothschedule/billing/migrations/0003_seed_initial_plans.py
index 3f15f2c..bc4b0b4 100644
--- a/smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py
+++ b/smoothschedule/smoothschedule/billing/migrations/0003_seed_initial_plans.py
@@ -291,6 +291,8 @@ class Migration(migrations.Migration):
]
operations = [
+ # Only seed features - plans should be created via Platform Settings UI
migrations.RunPython(seed_features, reverse_seed),
- migrations.RunPython(seed_plans_and_versions, migrations.RunPython.noop),
+ # NOTE: seed_plans_and_versions removed - plans are created via UI
+ # migrations.RunPython(seed_plans_and_versions, migrations.RunPython.noop),
]
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0004_migrate_tenants_to_subscriptions.py b/smoothschedule/smoothschedule/billing/migrations/0004_migrate_tenants_to_subscriptions.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/0004_migrate_tenants_to_subscriptions.py
rename to smoothschedule/smoothschedule/billing/migrations/0004_migrate_tenants_to_subscriptions.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py b/smoothschedule/smoothschedule/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py
rename to smoothschedule/smoothschedule/billing/migrations/0005_plan_allow_custom_domains_plan_max_custom_domains_and_more.py
diff --git a/smoothschedule/smoothschedule/billing/migrations/0006_add_plan_version_settings.py b/smoothschedule/smoothschedule/billing/migrations/0006_add_plan_version_settings.py
new file mode 100644
index 0000000..5944075
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0006_add_plan_version_settings.py
@@ -0,0 +1,133 @@
+# Generated by Django 5.2.8 on 2025-12-12 05:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0005_plan_allow_custom_domains_plan_max_custom_domains_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='planversion',
+ name='business_tier',
+ field=models.CharField(blank=True, help_text='Tier label: Free, Starter, Professional, Business, Enterprise', max_length=50),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='contracts_enabled',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='default_auto_reload_amount_cents',
+ field=models.PositiveIntegerField(default=2500, help_text='Amount to add when auto-reloading (in cents)'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='default_auto_reload_enabled',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='default_auto_reload_threshold_cents',
+ field=models.PositiveIntegerField(default=1000, help_text='Reload when balance falls below this amount (in cents)'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='is_most_popular',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='marketing_features',
+ field=models.JSONField(blank=True, default=list, help_text='List of feature descriptions for marketing display'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='masked_calling_enabled',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='masked_calling_price_per_minute_cents',
+ field=models.PositiveIntegerField(default=5, help_text='Price per minute of masked calling in cents'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_appointments_per_month',
+ field=models.IntegerField(default=100, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_automated_tasks',
+ field=models.IntegerField(default=5, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_email_templates',
+ field=models.IntegerField(default=5, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_resources',
+ field=models.IntegerField(default=10, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_services',
+ field=models.IntegerField(default=10, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='max_users',
+ field=models.IntegerField(default=5, help_text='-1 for unlimited'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='permissions',
+ field=models.JSONField(blank=True, default=dict, help_text='Boolean permissions like can_accept_payments, sms_reminders, etc.'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='proxy_number_enabled',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='proxy_number_monthly_fee_cents',
+ field=models.PositiveIntegerField(default=200, help_text='Monthly fee per proxy phone number in cents'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='show_price',
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='sms_enabled',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='sms_price_per_message_cents',
+ field=models.PositiveIntegerField(default=3, help_text='Price per SMS message in cents'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='transaction_fee_fixed_cents',
+ field=models.PositiveIntegerField(default=40, help_text='Platform transaction fixed fee in cents (e.g., 40 = $0.40)'),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='transaction_fee_percent',
+ field=models.DecimalField(decimal_places=2, default=4.0, help_text='Platform transaction fee percentage (e.g., 4.0 = 4%)', max_digits=5),
+ ),
+ migrations.AddField(
+ model_name='planversion',
+ name='trial_days',
+ field=models.PositiveIntegerField(default=0, help_text='Number of trial days for new subscribers'),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0007_remove_duplicate_feature_fields.py b/smoothschedule/smoothschedule/billing/migrations/0007_remove_duplicate_feature_fields.py
new file mode 100644
index 0000000..dc2f9fe
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0007_remove_duplicate_feature_fields.py
@@ -0,0 +1,72 @@
+# Generated by Django 5.2.8 on 2025-12-12 05:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0006_add_plan_version_settings'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='planversion',
+ name='contracts_enabled',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='masked_calling_enabled',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_appointments_per_month',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_automated_tasks',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_email_templates',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_resources',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_services',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='max_users',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='permissions',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='proxy_number_enabled',
+ ),
+ migrations.RemoveField(
+ model_name='planversion',
+ name='sms_enabled',
+ ),
+ migrations.AlterField(
+ model_name='planversion',
+ name='masked_calling_price_per_minute_cents',
+ field=models.PositiveIntegerField(default=5, help_text='Price per minute of masked calling in cents (if enabled)'),
+ ),
+ migrations.AlterField(
+ model_name='planversion',
+ name='proxy_number_monthly_fee_cents',
+ field=models.PositiveIntegerField(default=200, help_text='Monthly fee per proxy phone number in cents (if enabled)'),
+ ),
+ migrations.AlterField(
+ model_name='planversion',
+ name='sms_price_per_message_cents',
+ field=models.PositiveIntegerField(default=3, help_text='Price per SMS message in cents (if SMS feature enabled)'),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0008_seed_features.py b/smoothschedule/smoothschedule/billing/migrations/0008_seed_features.py
new file mode 100644
index 0000000..92331ac
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0008_seed_features.py
@@ -0,0 +1,261 @@
+# Generated by Claude on 2025-12-12
+
+from django.db import migrations
+
+
+def seed_features(apps, schema_editor):
+ """Seed the Feature model with all available features.
+
+ Features are capabilities that can be enabled/disabled or have limits per plan.
+ They are assigned to PlanVersions via the PlanFeature M2M relationship.
+ """
+ Feature = apps.get_model("billing", "Feature")
+
+ features = [
+ # =============================================================================
+ # Boolean Features (capabilities that can be enabled/disabled)
+ # =============================================================================
+ {
+ "code": "sms_enabled",
+ "name": "SMS Messaging",
+ "description": "Send SMS notifications and reminders to customers",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "masked_calling_enabled",
+ "name": "Masked Calling",
+ "description": "Make calls with masked caller ID for privacy",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "proxy_number_enabled",
+ "name": "Proxy Phone Numbers",
+ "description": "Use proxy phone numbers for customer communication",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "contracts_enabled",
+ "name": "Contracts & E-Signatures",
+ "description": "Create contracts and collect electronic signatures",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "can_use_plugins",
+ "name": "Plugin Integrations",
+ "description": "Use third-party plugin integrations",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "can_use_tasks",
+ "name": "Automated Tasks",
+ "description": "Create and run automated task workflows",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "can_use_analytics",
+ "name": "Analytics Dashboard",
+ "description": "Access business analytics and reporting",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "custom_branding",
+ "name": "Custom Branding",
+ "description": "Customize branding colors, logo, and styling",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "api_access",
+ "name": "API Access",
+ "description": "Access the public API for integrations",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "white_label",
+ "name": "White Label",
+ "description": "Remove SmoothSchedule branding completely",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "priority_support",
+ "name": "Priority Support",
+ "description": "Get priority customer support response",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "sso_enabled",
+ "name": "Single Sign-On (SSO)",
+ "description": "Enable SSO authentication for team members",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "custom_fields",
+ "name": "Custom Fields",
+ "description": "Create custom data fields for resources and events",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "webhooks_enabled",
+ "name": "Webhooks",
+ "description": "Send webhook notifications for events",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "online_payments",
+ "name": "Online Payments",
+ "description": "Accept online payments from customers",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "recurring_appointments",
+ "name": "Recurring Appointments",
+ "description": "Schedule recurring appointments",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "group_bookings",
+ "name": "Group Bookings",
+ "description": "Allow multiple customers per appointment",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "waitlist",
+ "name": "Waitlist",
+ "description": "Enable waitlist for fully booked slots",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "calendar_sync",
+ "name": "Calendar Sync",
+ "description": "Sync with Google Calendar, Outlook, etc.",
+ "feature_type": "boolean",
+ },
+ {
+ "code": "customer_portal",
+ "name": "Customer Portal",
+ "description": "Branded self-service portal for customers",
+ "feature_type": "boolean",
+ },
+ # =============================================================================
+ # Integer Features (limits and quotas)
+ # =============================================================================
+ {
+ "code": "max_users",
+ "name": "Maximum Team Members",
+ "description": "Maximum number of team member accounts (0 = unlimited)",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_resources",
+ "name": "Maximum Resources",
+ "description": "Maximum number of resources (staff, rooms, equipment). 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_services",
+ "name": "Maximum Services",
+ "description": "Maximum number of service types. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_appointments_per_month",
+ "name": "Monthly Appointment Limit",
+ "description": "Maximum appointments per month. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_email_templates",
+ "name": "Email Template Limit",
+ "description": "Maximum number of custom email templates. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_automated_tasks",
+ "name": "Automated Task Limit",
+ "description": "Maximum number of automated tasks. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_customers",
+ "name": "Customer Limit",
+ "description": "Maximum number of customer records. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_locations",
+ "name": "Location Limit",
+ "description": "Maximum number of business locations. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "storage_gb",
+ "name": "Storage (GB)",
+ "description": "File storage limit in gigabytes. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ {
+ "code": "max_api_requests_per_day",
+ "name": "Daily API Request Limit",
+ "description": "Maximum API requests per day. 0 = unlimited",
+ "feature_type": "integer",
+ },
+ ]
+
+ for feature_data in features:
+ Feature.objects.update_or_create(
+ code=feature_data["code"],
+ defaults={
+ "name": feature_data["name"],
+ "description": feature_data["description"],
+ "feature_type": feature_data["feature_type"],
+ },
+ )
+
+
+def reverse_seed_features(apps, schema_editor):
+ """Remove seeded features."""
+ Feature = apps.get_model("billing", "Feature")
+ Feature.objects.filter(
+ code__in=[
+ "sms_enabled",
+ "masked_calling_enabled",
+ "proxy_number_enabled",
+ "contracts_enabled",
+ "can_use_plugins",
+ "can_use_tasks",
+ "can_use_analytics",
+ "custom_branding",
+ "api_access",
+ "white_label",
+ "priority_support",
+ "sso_enabled",
+ "custom_fields",
+ "webhooks_enabled",
+ "online_payments",
+ "recurring_appointments",
+ "group_bookings",
+ "waitlist",
+ "calendar_sync",
+ "customer_portal",
+ "max_users",
+ "max_resources",
+ "max_services",
+ "max_appointments_per_month",
+ "max_email_templates",
+ "max_automated_tasks",
+ "max_customers",
+ "max_locations",
+ "storage_gb",
+ "max_api_requests_per_day",
+ ]
+ ).delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("billing", "0007_remove_duplicate_feature_fields"),
+ ]
+
+ operations = [
+ migrations.RunPython(seed_features, reverse_seed_features),
+ ]
diff --git a/smoothschedule/smoothschedule/billing/migrations/0009_remove_business_tier.py b/smoothschedule/smoothschedule/billing/migrations/0009_remove_business_tier.py
new file mode 100644
index 0000000..5e6d6ea
--- /dev/null
+++ b/smoothschedule/smoothschedule/billing/migrations/0009_remove_business_tier.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.8 on 2025-12-12 06:06
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('billing', '0008_seed_features'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='planversion',
+ name='business_tier',
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/__init__.py b/smoothschedule/smoothschedule/billing/migrations/__init__.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/migrations/__init__.py
rename to smoothschedule/smoothschedule/billing/migrations/__init__.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/models.py b/smoothschedule/smoothschedule/billing/models.py
similarity index 86%
rename from smoothschedule/smoothschedule/commerce/billing/models.py
rename to smoothschedule/smoothschedule/billing/models.py
index d7e53c0..1adad2e 100644
--- a/smoothschedule/smoothschedule/commerce/billing/models.py
+++ b/smoothschedule/smoothschedule/billing/models.py
@@ -72,6 +72,10 @@ class PlanVersion(models.Model):
Legacy versions (is_legacy=True) are hidden from new signups but
existing subscribers can continue using them (grandfathering).
+
+ IMPORTANT: Features/permissions/limits are stored via the PlanFeature
+ M2M relationship, not as direct fields. Use EntitlementService to
+ resolve what a tenant can do based on their subscription.
"""
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="versions")
@@ -90,6 +94,58 @@ class PlanVersion(models.Model):
price_monthly_cents = models.PositiveIntegerField(default=0)
price_yearly_cents = models.PositiveIntegerField(default=0)
+ # Transaction fees (platform revenue from tenant transactions)
+ transaction_fee_percent = models.DecimalField(
+ max_digits=5, decimal_places=2, default=4.0,
+ help_text="Platform transaction fee percentage (e.g., 4.0 = 4%)"
+ )
+ transaction_fee_fixed_cents = models.PositiveIntegerField(
+ default=40,
+ help_text="Platform transaction fixed fee in cents (e.g., 40 = $0.40)"
+ )
+
+ # Trial period
+ trial_days = models.PositiveIntegerField(
+ default=0,
+ help_text="Number of trial days for new subscribers"
+ )
+
+ # Communication pricing (cost to tenant when using these features)
+ # Note: Whether the feature is ENABLED is controlled by Feature flags
+ sms_price_per_message_cents = models.PositiveIntegerField(
+ default=3,
+ help_text="Price per SMS message in cents (if SMS feature enabled)"
+ )
+ masked_calling_price_per_minute_cents = models.PositiveIntegerField(
+ default=5,
+ help_text="Price per minute of masked calling in cents (if enabled)"
+ )
+ proxy_number_monthly_fee_cents = models.PositiveIntegerField(
+ default=200,
+ help_text="Monthly fee per proxy phone number in cents (if enabled)"
+ )
+
+ # Default credit settings for new businesses on this plan
+ default_auto_reload_enabled = models.BooleanField(default=False)
+ default_auto_reload_threshold_cents = models.PositiveIntegerField(
+ default=1000,
+ help_text="Reload when balance falls below this amount (in cents)"
+ )
+ default_auto_reload_amount_cents = models.PositiveIntegerField(
+ default=2500,
+ help_text="Amount to add when auto-reloading (in cents)"
+ )
+
+ # Display settings (for marketing/pricing pages)
+ is_most_popular = models.BooleanField(default=False)
+ show_price = models.BooleanField(default=True)
+
+ # Marketing features list (display-only strings for pricing page)
+ marketing_features = models.JSONField(
+ default=list, blank=True,
+ help_text="List of feature descriptions for marketing display"
+ )
+
# Stripe integration
stripe_product_id = models.CharField(max_length=100, blank=True)
stripe_price_id_monthly = models.CharField(max_length=100, blank=True)
diff --git a/smoothschedule/smoothschedule/commerce/billing/services/__init__.py b/smoothschedule/smoothschedule/billing/services/__init__.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/services/__init__.py
rename to smoothschedule/smoothschedule/billing/services/__init__.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/services/entitlements.py b/smoothschedule/smoothschedule/billing/services/entitlements.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/services/entitlements.py
rename to smoothschedule/smoothschedule/billing/services/entitlements.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/services/invoicing.py b/smoothschedule/smoothschedule/billing/services/invoicing.py
similarity index 94%
rename from smoothschedule/smoothschedule/commerce/billing/services/invoicing.py
rename to smoothschedule/smoothschedule/billing/services/invoicing.py
index d6e8b73..d021796 100644
--- a/smoothschedule/smoothschedule/commerce/billing/services/invoicing.py
+++ b/smoothschedule/smoothschedule/billing/services/invoicing.py
@@ -10,10 +10,10 @@ from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
-from smoothschedule.commerce.billing.models import Invoice
-from smoothschedule.commerce.billing.models import InvoiceLine
+from smoothschedule.billing.models import Invoice
+from smoothschedule.billing.models import InvoiceLine
def generate_invoice_for_subscription(
diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/__init__.py b/smoothschedule/smoothschedule/billing/tests/__init__.py
similarity index 100%
rename from smoothschedule/smoothschedule/commerce/billing/tests/__init__.py
rename to smoothschedule/smoothschedule/billing/tests/__init__.py
diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_api.py b/smoothschedule/smoothschedule/billing/tests/test_api.py
similarity index 86%
rename from smoothschedule/smoothschedule/commerce/billing/tests/test_api.py
rename to smoothschedule/smoothschedule/billing/tests/test_api.py
index 6738184..26a35b9 100644
--- a/smoothschedule/smoothschedule/commerce/billing/tests/test_api.py
+++ b/smoothschedule/smoothschedule/billing/tests/test_api.py
@@ -23,7 +23,7 @@ from rest_framework.test import APIClient
@pytest.fixture
def clean_tenant_subscription(shared_tenant):
"""Delete any existing subscription for shared_tenant before test."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
Subscription.objects.filter(business=shared_tenant).delete()
yield shared_tenant
@@ -31,7 +31,7 @@ def clean_tenant_subscription(shared_tenant):
@pytest.fixture
def clean_second_tenant_subscription(second_shared_tenant):
"""Delete any existing subscription for second_shared_tenant before test."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
Subscription.objects.filter(business=second_shared_tenant).delete()
yield second_shared_tenant
@@ -46,14 +46,14 @@ class TestEntitlementsEndpoint:
def test_returns_entitlements_from_service(self):
"""Endpoint should return dict from EntitlementService."""
- from smoothschedule.commerce.billing.api.views import EntitlementsView
+ from smoothschedule.billing.api.views import EntitlementsView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
with patch(
- "smoothschedule.commerce.billing.api.views.EntitlementService"
+ "smoothschedule.billing.api.views.EntitlementService"
) as MockService:
MockService.get_effective_entitlements.return_value = {
"sms": True,
@@ -73,14 +73,14 @@ class TestEntitlementsEndpoint:
def test_returns_empty_dict_when_no_subscription(self):
"""Endpoint should return empty dict when no subscription."""
- from smoothschedule.commerce.billing.api.views import EntitlementsView
+ from smoothschedule.billing.api.views import EntitlementsView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
with patch(
- "smoothschedule.commerce.billing.api.views.EntitlementService"
+ "smoothschedule.billing.api.views.EntitlementService"
) as MockService:
MockService.get_effective_entitlements.return_value = {}
@@ -103,7 +103,7 @@ class TestSubscriptionEndpoint:
def test_returns_subscription_with_is_legacy_flag(self):
"""Subscription response should include is_legacy flag."""
- from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
+ from smoothschedule.billing.api.views import CurrentSubscriptionView
mock_subscription = Mock()
mock_subscription.id = 1
@@ -126,7 +126,7 @@ class TestSubscriptionEndpoint:
view.request = mock_request
with patch(
- "smoothschedule.commerce.billing.api.views.SubscriptionSerializer"
+ "smoothschedule.billing.api.views.SubscriptionSerializer"
) as MockSerializer:
mock_serializer = Mock()
mock_serializer.data = {
@@ -148,7 +148,7 @@ class TestSubscriptionEndpoint:
def test_returns_404_when_no_subscription(self):
"""Should return 404 when tenant has no subscription."""
- from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
+ from smoothschedule.billing.api.views import CurrentSubscriptionView
mock_request = Mock()
mock_request.user = Mock()
@@ -174,9 +174,9 @@ class TestPlansEndpoint:
def test_filters_by_is_public_true(self):
"""Should only return public plan versions."""
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.api.views import PlanCatalogView
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.api.views import PlanCatalogView
# Create public and non-public plans
plan = Plan.objects.create(code="test_public", name="Test Public Plan")
@@ -200,9 +200,9 @@ class TestPlansEndpoint:
def test_excludes_legacy_plans(self):
"""Should exclude legacy plan versions."""
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.api.views import PlanCatalogView
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.api.views import PlanCatalogView
# Create legacy and non-legacy plans
plan = Plan.objects.create(code="test_legacy", name="Test Legacy Plan")
@@ -238,10 +238,10 @@ class TestInvoicesEndpointIsolation:
self, clean_tenant_subscription, clean_second_tenant_subscription
):
"""A tenant should only see their own invoices."""
- from smoothschedule.commerce.billing.models import Invoice
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Invoice
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
from smoothschedule.identity.users.models import User
shared_tenant = clean_tenant_subscription
@@ -306,10 +306,10 @@ class TestInvoicesEndpointIsolation:
self, clean_tenant_subscription, clean_second_tenant_subscription
):
"""Requesting another tenant's invoice should return 404."""
- from smoothschedule.commerce.billing.models import Invoice
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Invoice
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
shared_tenant = clean_tenant_subscription
second_shared_tenant = clean_second_tenant_subscription
@@ -358,7 +358,7 @@ class TestAddOnsEndpoint:
def test_returns_active_addons_only(self):
"""Should only return active add-on products."""
- from smoothschedule.commerce.billing.api.views import AddOnCatalogView
+ from smoothschedule.billing.api.views import AddOnCatalogView
mock_request = Mock()
mock_request.user = Mock()
@@ -368,14 +368,14 @@ class TestAddOnsEndpoint:
view.request = mock_request
with patch(
- "smoothschedule.commerce.billing.api.views.AddOnProduct"
+ "smoothschedule.billing.api.views.AddOnProduct"
) as MockAddOn:
mock_queryset = Mock()
MockAddOn.objects.filter.return_value = mock_queryset
mock_queryset.all.return_value = []
with patch(
- "smoothschedule.commerce.billing.api.views.AddOnProductSerializer"
+ "smoothschedule.billing.api.views.AddOnProductSerializer"
):
view.get(mock_request)
diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py b/smoothschedule/smoothschedule/billing/tests/test_entitlements.py
similarity index 93%
rename from smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py
rename to smoothschedule/smoothschedule/billing/tests/test_entitlements.py
index 8369271..5f73591 100644
--- a/smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py
+++ b/smoothschedule/smoothschedule/billing/tests/test_entitlements.py
@@ -118,7 +118,7 @@ class TestGetEffectiveEntitlements:
def test_returns_empty_dict_when_no_subscription(self):
"""Should return empty dict when business has no subscription."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -131,7 +131,7 @@ class TestGetEffectiveEntitlements:
def test_returns_base_plan_features(self):
"""Should return features from the base plan when no add-ons or overrides."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -167,7 +167,7 @@ class TestGetEffectiveEntitlements:
def test_addon_features_stack_on_plan_features(self):
"""Add-on features should be added to the result alongside plan features."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -211,7 +211,7 @@ class TestGetEffectiveEntitlements:
def test_override_takes_precedence_over_plan_and_addon(self):
"""EntitlementOverride should override both plan and add-on values."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -251,7 +251,7 @@ class TestGetEffectiveEntitlements:
def test_expired_override_is_ignored(self):
"""Expired overrides should not affect the result."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -295,7 +295,7 @@ class TestGetEffectiveEntitlements:
def test_expired_addon_is_ignored(self):
"""Expired add-ons should not affect the result."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -334,7 +334,7 @@ class TestGetEffectiveEntitlements:
def test_canceled_addon_is_ignored(self):
"""Canceled add-ons should not affect the result."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -372,7 +372,7 @@ class TestGetEffectiveEntitlements:
def test_integer_limits_highest_value_wins(self):
"""When multiple sources grant an integer feature, highest value wins."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -417,7 +417,7 @@ class TestGetEffectiveEntitlements:
def test_returns_empty_when_subscription_not_active(self):
"""Should return empty dict when subscription is not active."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -442,7 +442,7 @@ class TestHasFeature:
def test_returns_true_for_enabled_boolean_feature(self):
"""has_feature should return True when feature is enabled."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -458,7 +458,7 @@ class TestHasFeature:
def test_returns_false_for_disabled_boolean_feature(self):
"""has_feature should return False when feature is disabled."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -473,7 +473,7 @@ class TestHasFeature:
def test_returns_false_for_missing_feature(self):
"""has_feature should return False when feature is not in entitlements."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -488,7 +488,7 @@ class TestHasFeature:
def test_returns_true_for_non_zero_integer_feature(self):
"""has_feature should return True for non-zero integer limits."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -503,7 +503,7 @@ class TestHasFeature:
def test_returns_false_for_zero_integer_feature(self):
"""has_feature should return False for zero integer limits."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -527,7 +527,7 @@ class TestGetLimit:
def test_returns_integer_value(self):
"""get_limit should return the integer value for integer features."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -543,7 +543,7 @@ class TestGetLimit:
def test_returns_none_for_missing_feature(self):
"""get_limit should return None for missing features."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
@@ -558,7 +558,7 @@ class TestGetLimit:
def test_returns_none_for_boolean_feature(self):
"""get_limit should return None for boolean features."""
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py b/smoothschedule/smoothschedule/billing/tests/test_invoicing.py
similarity index 80%
rename from smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py
rename to smoothschedule/smoothschedule/billing/tests/test_invoicing.py
index 9a876d8..65aaeb5 100644
--- a/smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py
+++ b/smoothschedule/smoothschedule/billing/tests/test_invoicing.py
@@ -20,7 +20,7 @@ from django.utils import timezone
@pytest.fixture
def clean_tenant_subscription(shared_tenant):
"""Delete any existing subscription for shared_tenant before test."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
Subscription.objects.filter(business=shared_tenant).delete()
yield shared_tenant
@@ -36,17 +36,19 @@ class TestGenerateInvoiceForSubscription:
def test_creates_invoice_with_plan_snapshots(self, clean_tenant_subscription):
"""Invoice should capture plan name and code at billing time."""
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
- from smoothschedule.commerce.billing.services.invoicing import (
+ import uuid
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
+ from smoothschedule.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
+ unique_id = str(uuid.uuid4())[:8]
# Create plan and subscription
- plan = Plan.objects.create(code="pro", name="Pro")
+ plan = Plan.objects.create(code=f"pro_{unique_id}", name="Pro")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Pro Plan v1", price_monthly_cents=2999
)
@@ -67,16 +69,16 @@ class TestGenerateInvoiceForSubscription:
)
# Verify snapshot values
- assert invoice.plan_code_at_billing == "pro"
+ assert invoice.plan_code_at_billing == f"pro_{unique_id}"
assert invoice.plan_name_at_billing == "Pro Plan v1"
assert invoice.plan_version_id_at_billing == pv.id
def test_creates_line_item_for_base_plan(self, clean_tenant_subscription):
"""Invoice should have a line item for the base plan subscription."""
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
- from smoothschedule.commerce.billing.services.invoicing import (
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
+ from smoothschedule.billing.services.invoicing import (
generate_invoice_for_subscription,
)
@@ -111,12 +113,12 @@ class TestGenerateInvoiceForSubscription:
def test_creates_line_items_for_active_addons(self, clean_tenant_subscription):
"""Invoice should have line items for each active add-on."""
- from smoothschedule.commerce.billing.models import AddOnProduct
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
- from smoothschedule.commerce.billing.services.invoicing import (
+ from smoothschedule.billing.models import AddOnProduct
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
+ from smoothschedule.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.services.invoicing import (
generate_invoice_for_subscription,
)
@@ -158,12 +160,12 @@ class TestGenerateInvoiceForSubscription:
def test_calculates_totals_correctly(self, clean_tenant_subscription):
"""Invoice totals should be calculated from line items."""
- from smoothschedule.commerce.billing.models import AddOnProduct
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
- from smoothschedule.commerce.billing.services.invoicing import (
+ from smoothschedule.billing.models import AddOnProduct
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
+ from smoothschedule.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.services.invoicing import (
generate_invoice_for_subscription,
)
@@ -201,12 +203,12 @@ class TestGenerateInvoiceForSubscription:
def test_skips_inactive_addons(self, clean_tenant_subscription):
"""Inactive add-ons should not be included in the invoice."""
- from smoothschedule.commerce.billing.models import AddOnProduct
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
- from smoothschedule.commerce.billing.services.invoicing import (
+ from smoothschedule.billing.models import AddOnProduct
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
+ from smoothschedule.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.services.invoicing import (
generate_invoice_for_subscription,
)
@@ -261,11 +263,11 @@ class TestInvoiceImmutability:
Changing a PlanVersion's price should NOT affect existing invoices.
This verifies the snapshot design.
"""
- from smoothschedule.commerce.billing.models import Invoice
- from smoothschedule.commerce.billing.models import InvoiceLine
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Invoice
+ from smoothschedule.billing.models import InvoiceLine
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
+ from smoothschedule.billing.models import Subscription
shared_tenant = clean_tenant_subscription
diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_models.py b/smoothschedule/smoothschedule/billing/tests/test_models.py
similarity index 83%
rename from smoothschedule/smoothschedule/commerce/billing/tests/test_models.py
rename to smoothschedule/smoothschedule/billing/tests/test_models.py
index bd31cb9..7228142 100644
--- a/smoothschedule/smoothschedule/commerce/billing/tests/test_models.py
+++ b/smoothschedule/smoothschedule/billing/tests/test_models.py
@@ -22,7 +22,7 @@ class TestFeatureModel:
def test_feature_has_required_fields(self):
"""Feature model should have code, name, feature_type fields."""
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import Feature
# Check model has expected fields
field_names = [f.name for f in Feature._meta.get_fields()]
@@ -34,7 +34,7 @@ class TestFeatureModel:
def test_feature_type_choices(self):
"""Feature should support boolean and integer types."""
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import Feature
feature = Feature(
code="test_feature",
@@ -52,7 +52,7 @@ class TestFeatureModel:
def test_feature_str_representation(self):
"""Feature __str__ should return the feature name."""
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import Feature
feature = Feature(code="sms", name="SMS Notifications")
assert str(feature) == "SMS Notifications"
@@ -68,7 +68,7 @@ class TestPlanModel:
def test_plan_has_required_fields(self):
"""Plan model should have code, name, display_order, is_active fields."""
- from smoothschedule.commerce.billing.models import Plan
+ from smoothschedule.billing.models import Plan
field_names = [f.name for f in Plan._meta.get_fields()]
assert "code" in field_names
@@ -78,14 +78,14 @@ class TestPlanModel:
def test_plan_str_representation(self):
"""Plan __str__ should return the plan name."""
- from smoothschedule.commerce.billing.models import Plan
+ from smoothschedule.billing.models import Plan
plan = Plan(code="pro", name="Pro Plan")
assert str(plan) == "Pro Plan"
def test_plan_default_values(self):
"""Plan should have sensible defaults."""
- from smoothschedule.commerce.billing.models import Plan
+ from smoothschedule.billing.models import Plan
plan = Plan(code="starter", name="Starter")
assert plan.is_active is True
@@ -102,7 +102,7 @@ class TestPlanVersionModel:
def test_plan_version_has_required_fields(self):
"""PlanVersion should have pricing, visibility, and Stripe fields."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
field_names = [f.name for f in PlanVersion._meta.get_fields()]
assert "plan" in field_names
@@ -120,7 +120,7 @@ class TestPlanVersionModel:
def test_plan_version_str_representation(self):
"""PlanVersion __str__ should return the version name."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
pv = PlanVersion(name="Pro Plan - 2024 Holiday Promo")
# Don't need to set plan for __str__ - it just uses name
@@ -128,28 +128,28 @@ class TestPlanVersionModel:
def test_plan_version_is_available_when_public_and_no_date_constraints(self):
"""PlanVersion.is_available should return True for public versions with no dates."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
pv = PlanVersion(is_public=True, is_legacy=False, starts_at=None, ends_at=None)
assert pv.is_available is True
def test_plan_version_is_not_available_when_legacy(self):
"""Legacy versions should not be available for new signups."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
pv = PlanVersion(is_public=True, is_legacy=True, starts_at=None, ends_at=None)
assert pv.is_available is False
def test_plan_version_is_not_available_when_not_public(self):
"""Non-public versions should not be available."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
pv = PlanVersion(is_public=False, is_legacy=False, starts_at=None, ends_at=None)
assert pv.is_available is False
def test_plan_version_is_available_within_date_window(self):
"""PlanVersion should be available within its date window."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
@@ -162,7 +162,7 @@ class TestPlanVersionModel:
def test_plan_version_is_not_available_before_start_date(self):
"""PlanVersion should not be available before its start date."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
@@ -175,7 +175,7 @@ class TestPlanVersionModel:
def test_plan_version_is_not_available_after_end_date(self):
"""PlanVersion should not be available after its end date."""
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
@@ -197,7 +197,7 @@ class TestPlanFeatureModel:
def test_plan_feature_has_required_fields(self):
"""PlanFeature should have plan_version, feature, and value fields."""
- from smoothschedule.commerce.billing.models import PlanFeature
+ from smoothschedule.billing.models import PlanFeature
field_names = [f.name for f in PlanFeature._meta.get_fields()]
assert "plan_version" in field_names
@@ -208,10 +208,10 @@ class TestPlanFeatureModel:
@pytest.mark.django_db
def test_plan_feature_get_value_returns_bool(self):
"""get_value should return bool_value for boolean features."""
- from smoothschedule.commerce.billing.models import Feature
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanFeature
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import Feature
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanFeature
+ from smoothschedule.billing.models import PlanVersion
# Create real instances since Django ForeignKey doesn't accept Mock
feature = Feature.objects.create(
@@ -228,10 +228,10 @@ class TestPlanFeatureModel:
@pytest.mark.django_db
def test_plan_feature_get_value_returns_int(self):
"""get_value should return int_value for integer features."""
- from smoothschedule.commerce.billing.models import Feature
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanFeature
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import Feature
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanFeature
+ from smoothschedule.billing.models import PlanVersion
feature = Feature.objects.create(
code="test_int_feature",
@@ -255,7 +255,7 @@ class TestSubscriptionModel:
def test_subscription_has_required_fields(self):
"""Subscription should have business, plan_version, status, dates, etc."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
field_names = [f.name for f in Subscription._meta.get_fields()]
assert "business" in field_names
@@ -270,7 +270,7 @@ class TestSubscriptionModel:
def test_subscription_status_choices(self):
"""Subscription should support trial, active, past_due, canceled statuses."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
valid_statuses = ["trial", "active", "past_due", "canceled"]
for status in valid_statuses:
@@ -279,28 +279,28 @@ class TestSubscriptionModel:
def test_subscription_is_active_when_status_is_active(self):
"""is_active property should return True for active subscriptions."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
sub = Subscription(status="active")
assert sub.is_active is True
def test_subscription_is_active_when_status_is_trial(self):
"""Trial subscriptions should be considered active."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
sub = Subscription(status="trial")
assert sub.is_active is True
def test_subscription_is_not_active_when_canceled(self):
"""Canceled subscriptions should not be considered active."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
sub = Subscription(status="canceled")
assert sub.is_active is False
def test_subscription_is_not_active_when_past_due(self):
"""Past due subscriptions should not be considered active."""
- from smoothschedule.commerce.billing.models import Subscription
+ from smoothschedule.billing.models import Subscription
sub = Subscription(status="past_due")
assert sub.is_active is False
@@ -316,7 +316,7 @@ class TestAddOnProductModel:
def test_addon_has_required_fields(self):
"""AddOnProduct should have code, name, pricing, Stripe fields."""
- from smoothschedule.commerce.billing.models import AddOnProduct
+ from smoothschedule.billing.models import AddOnProduct
field_names = [f.name for f in AddOnProduct._meta.get_fields()]
assert "code" in field_names
@@ -330,7 +330,7 @@ class TestAddOnProductModel:
def test_addon_str_representation(self):
"""AddOnProduct __str__ should return the addon name."""
- from smoothschedule.commerce.billing.models import AddOnProduct
+ from smoothschedule.billing.models import AddOnProduct
addon = AddOnProduct(code="sms_pack", name="SMS Pack (1000)")
assert str(addon) == "SMS Pack (1000)"
@@ -346,7 +346,7 @@ class TestAddOnFeatureModel:
def test_addon_feature_has_required_fields(self):
"""AddOnFeature should have addon, feature, and value fields."""
- from smoothschedule.commerce.billing.models import AddOnFeature
+ from smoothschedule.billing.models import AddOnFeature
field_names = [f.name for f in AddOnFeature._meta.get_fields()]
assert "addon" in field_names
@@ -365,7 +365,7 @@ class TestSubscriptionAddOnModel:
def test_subscription_addon_has_required_fields(self):
"""SubscriptionAddOn should have subscription, addon, status, dates."""
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.models import SubscriptionAddOn
field_names = [f.name for f in SubscriptionAddOn._meta.get_fields()]
assert "subscription" in field_names
@@ -378,14 +378,14 @@ class TestSubscriptionAddOnModel:
def test_subscription_addon_is_active_when_status_active_no_expiry(self):
"""is_active should return True for active add-ons without expiry."""
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.models import SubscriptionAddOn
sa = SubscriptionAddOn(status="active", expires_at=None)
assert sa.is_active is True
def test_subscription_addon_is_active_when_status_active_future_expiry(self):
"""is_active should return True for active add-ons with future expiry."""
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.models import SubscriptionAddOn
future = timezone.now() + timedelta(days=30)
sa = SubscriptionAddOn(status="active", expires_at=future)
@@ -393,7 +393,7 @@ class TestSubscriptionAddOnModel:
def test_subscription_addon_is_not_active_when_expired(self):
"""is_active should return False for expired add-ons."""
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.models import SubscriptionAddOn
past = timezone.now() - timedelta(days=1)
sa = SubscriptionAddOn(status="active", expires_at=past)
@@ -401,7 +401,7 @@ class TestSubscriptionAddOnModel:
def test_subscription_addon_is_not_active_when_canceled(self):
"""is_active should return False for canceled add-ons."""
- from smoothschedule.commerce.billing.models import SubscriptionAddOn
+ from smoothschedule.billing.models import SubscriptionAddOn
sa = SubscriptionAddOn(status="canceled", expires_at=None)
assert sa.is_active is False
@@ -417,7 +417,7 @@ class TestEntitlementOverrideModel:
def test_override_has_required_fields(self):
"""EntitlementOverride should have business, feature, source, value fields."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import EntitlementOverride
field_names = [f.name for f in EntitlementOverride._meta.get_fields()]
assert "business" in field_names
@@ -432,7 +432,7 @@ class TestEntitlementOverrideModel:
def test_override_source_choices(self):
"""EntitlementOverride should support manual, promo, support sources."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import EntitlementOverride
valid_sources = ["manual", "promo", "support"]
for source in valid_sources:
@@ -441,14 +441,14 @@ class TestEntitlementOverrideModel:
def test_override_is_active_when_no_expiry(self):
"""is_active should return True for overrides without expiry."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import EntitlementOverride
override = EntitlementOverride(expires_at=None)
assert override.is_active is True
def test_override_is_active_when_future_expiry(self):
"""is_active should return True for overrides with future expiry."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import EntitlementOverride
future = timezone.now() + timedelta(days=30)
override = EntitlementOverride(expires_at=future)
@@ -456,7 +456,7 @@ class TestEntitlementOverrideModel:
def test_override_is_not_active_when_expired(self):
"""is_active should return False for expired overrides."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import EntitlementOverride
past = timezone.now() - timedelta(days=1)
override = EntitlementOverride(expires_at=past)
@@ -465,8 +465,8 @@ class TestEntitlementOverrideModel:
@pytest.mark.django_db
def test_override_get_value_returns_bool(self):
"""get_value should return bool_value for boolean features."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import Feature
feature = Feature.objects.create(
code="override_test_bool",
@@ -480,8 +480,8 @@ class TestEntitlementOverrideModel:
@pytest.mark.django_db
def test_override_get_value_returns_int(self):
"""get_value should return int_value for integer features."""
- from smoothschedule.commerce.billing.models import EntitlementOverride
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import EntitlementOverride
+ from smoothschedule.billing.models import Feature
feature = Feature.objects.create(
code="override_test_int",
@@ -508,7 +508,7 @@ class TestModelConstraints:
from django.db import IntegrityError
- from smoothschedule.commerce.billing.models import Feature
+ from smoothschedule.billing.models import Feature
unique_code = f"test_feature_{uuid.uuid4().hex[:8]}"
Feature.objects.create(code=unique_code, name="Test Feature", feature_type="boolean")
@@ -522,7 +522,7 @@ class TestModelConstraints:
from django.db import IntegrityError
- from smoothschedule.commerce.billing.models import Plan
+ from smoothschedule.billing.models import Plan
unique_code = f"test_plan_{uuid.uuid4().hex[:8]}"
Plan.objects.create(code=unique_code, name="Test Plan")
@@ -536,8 +536,8 @@ class TestModelConstraints:
from django.db import IntegrityError
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanVersion
unique_code = f"test_plan_{uuid.uuid4().hex[:8]}"
plan = Plan.objects.create(code=unique_code, name="Test Plan")
@@ -552,10 +552,10 @@ class TestModelConstraints:
from django.db import IntegrityError
- from smoothschedule.commerce.billing.models import Feature
- from smoothschedule.commerce.billing.models import Plan
- from smoothschedule.commerce.billing.models import PlanFeature
- from smoothschedule.commerce.billing.models import PlanVersion
+ from smoothschedule.billing.models import Feature
+ from smoothschedule.billing.models import Plan
+ from smoothschedule.billing.models import PlanFeature
+ from smoothschedule.billing.models import PlanVersion
unique_plan_code = f"test_plan_{uuid.uuid4().hex[:8]}"
unique_feature_code = f"test_feature_{uuid.uuid4().hex[:8]}"
@@ -577,7 +577,7 @@ class TestModelConstraints:
from django.db import IntegrityError
- from smoothschedule.commerce.billing.models import AddOnProduct
+ from smoothschedule.billing.models import AddOnProduct
unique_code = f"test_addon_{uuid.uuid4().hex[:8]}"
AddOnProduct.objects.create(code=unique_code, name="Test Addon")
diff --git a/smoothschedule/smoothschedule/commerce/billing/api/serializers.py b/smoothschedule/smoothschedule/commerce/billing/api/serializers.py
deleted file mode 100644
index 0fab279..0000000
--- a/smoothschedule/smoothschedule/commerce/billing/api/serializers.py
+++ /dev/null
@@ -1,214 +0,0 @@
-"""
-DRF serializers for billing API endpoints.
-"""
-
-from rest_framework import serializers
-
-from smoothschedule.commerce.billing.models import AddOnProduct
-from smoothschedule.commerce.billing.models import Feature
-from smoothschedule.commerce.billing.models import Invoice
-from smoothschedule.commerce.billing.models import InvoiceLine
-from smoothschedule.commerce.billing.models import Plan
-from smoothschedule.commerce.billing.models import PlanFeature
-from smoothschedule.commerce.billing.models import PlanVersion
-from smoothschedule.commerce.billing.models import Subscription
-from smoothschedule.commerce.billing.models import SubscriptionAddOn
-
-
-class FeatureSerializer(serializers.ModelSerializer):
- """Serializer for Feature model."""
-
- class Meta:
- model = Feature
- fields = ["id", "code", "name", "description", "feature_type"]
-
-
-class PlanSerializer(serializers.ModelSerializer):
- """Serializer for Plan model."""
-
- class Meta:
- model = Plan
- fields = ["id", "code", "name", "description", "display_order", "is_active"]
-
-
-class PlanFeatureSerializer(serializers.ModelSerializer):
- """Serializer for PlanFeature model."""
-
- feature = FeatureSerializer(read_only=True)
- value = serializers.SerializerMethodField()
-
- class Meta:
- model = PlanFeature
- fields = ["id", "feature", "bool_value", "int_value", "value"]
-
- def get_value(self, obj):
- """Return the effective value based on feature type."""
- return obj.get_value()
-
-
-class PlanVersionSerializer(serializers.ModelSerializer):
- """Serializer for PlanVersion model."""
-
- plan = PlanSerializer(read_only=True)
- features = PlanFeatureSerializer(many=True, read_only=True)
- is_available = serializers.BooleanField(read_only=True)
-
- class Meta:
- model = PlanVersion
- fields = [
- "id",
- "plan",
- "version",
- "name",
- "is_public",
- "is_legacy",
- "starts_at",
- "ends_at",
- "price_monthly_cents",
- "price_yearly_cents",
- "is_available",
- "features",
- "created_at",
- ]
-
-
-class PlanVersionSummarySerializer(serializers.ModelSerializer):
- """Lightweight serializer for PlanVersion without features."""
-
- plan_code = serializers.CharField(source="plan.code", read_only=True)
- plan_name = serializers.CharField(source="plan.name", read_only=True)
-
- class Meta:
- model = PlanVersion
- fields = [
- "id",
- "plan_code",
- "plan_name",
- "version",
- "name",
- "is_legacy",
- "price_monthly_cents",
- "price_yearly_cents",
- ]
-
-
-class AddOnProductSerializer(serializers.ModelSerializer):
- """Serializer for AddOnProduct model."""
-
- class Meta:
- model = AddOnProduct
- fields = [
- "id",
- "code",
- "name",
- "description",
- "price_monthly_cents",
- "price_one_time_cents",
- "is_active",
- ]
-
-
-class SubscriptionAddOnSerializer(serializers.ModelSerializer):
- """Serializer for SubscriptionAddOn model."""
-
- addon = AddOnProductSerializer(read_only=True)
- is_active = serializers.BooleanField(read_only=True)
-
- class Meta:
- model = SubscriptionAddOn
- fields = [
- "id",
- "addon",
- "status",
- "activated_at",
- "expires_at",
- "is_active",
- ]
-
-
-class SubscriptionSerializer(serializers.ModelSerializer):
- """Serializer for Subscription model."""
-
- plan_version = PlanVersionSummarySerializer(read_only=True)
- addons = SubscriptionAddOnSerializer(many=True, read_only=True)
- is_active = serializers.BooleanField(read_only=True)
-
- class Meta:
- model = Subscription
- fields = [
- "id",
- "plan_version",
- "status",
- "is_active",
- "started_at",
- "current_period_start",
- "current_period_end",
- "trial_ends_at",
- "canceled_at",
- "addons",
- "created_at",
- "updated_at",
- ]
-
-
-class InvoiceLineSerializer(serializers.ModelSerializer):
- """Serializer for InvoiceLine model."""
-
- class Meta:
- model = InvoiceLine
- fields = [
- "id",
- "line_type",
- "description",
- "quantity",
- "unit_amount",
- "subtotal_amount",
- "tax_amount",
- "total_amount",
- "feature_code",
- "metadata",
- "created_at",
- ]
-
-
-class InvoiceSerializer(serializers.ModelSerializer):
- """Serializer for Invoice model."""
-
- lines = InvoiceLineSerializer(many=True, read_only=True)
-
- class Meta:
- model = Invoice
- fields = [
- "id",
- "period_start",
- "period_end",
- "currency",
- "subtotal_amount",
- "discount_amount",
- "tax_amount",
- "total_amount",
- "status",
- "plan_code_at_billing",
- "plan_name_at_billing",
- "stripe_invoice_id",
- "created_at",
- "paid_at",
- "lines",
- ]
-
-
-class InvoiceListSerializer(serializers.ModelSerializer):
- """Lightweight serializer for invoice list."""
-
- class Meta:
- model = Invoice
- fields = [
- "id",
- "period_start",
- "period_end",
- "total_amount",
- "status",
- "plan_name_at_billing",
- "created_at",
- "paid_at",
- ]
diff --git a/smoothschedule/smoothschedule/commerce/billing/api/urls.py b/smoothschedule/smoothschedule/commerce/billing/api/urls.py
deleted file mode 100644
index d3b8ec1..0000000
--- a/smoothschedule/smoothschedule/commerce/billing/api/urls.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""
-URL routes for billing API endpoints.
-"""
-
-from django.urls import path
-
-from smoothschedule.commerce.billing.api.views import AddOnCatalogView
-from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
-from smoothschedule.commerce.billing.api.views import EntitlementsView
-from smoothschedule.commerce.billing.api.views import InvoiceDetailView
-from smoothschedule.commerce.billing.api.views import InvoiceListView
-from smoothschedule.commerce.billing.api.views import PlanCatalogView
-
-app_name = "billing"
-
-urlpatterns = [
- # /api/me/ endpoints (current user/business context)
- path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
- path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
- # /api/billing/ endpoints
- path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
- path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
- path("billing/invoices/", InvoiceListView.as_view(), name="invoice-list"),
- path(
- "billing/invoices//",
- InvoiceDetailView.as_view(),
- name="invoice-detail",
- ),
-]
diff --git a/smoothschedule/smoothschedule/commerce/billing/api/views.py b/smoothschedule/smoothschedule/commerce/billing/api/views.py
deleted file mode 100644
index 5faf259..0000000
--- a/smoothschedule/smoothschedule/commerce/billing/api/views.py
+++ /dev/null
@@ -1,176 +0,0 @@
-"""
-DRF API views for billing endpoints.
-"""
-
-from rest_framework import status
-from rest_framework.permissions import IsAuthenticated
-from rest_framework.response import Response
-from rest_framework.views import APIView
-
-from smoothschedule.commerce.billing.api.serializers import AddOnProductSerializer
-from smoothschedule.commerce.billing.api.serializers import InvoiceListSerializer
-from smoothschedule.commerce.billing.api.serializers import InvoiceSerializer
-from smoothschedule.commerce.billing.api.serializers import PlanVersionSerializer
-from smoothschedule.commerce.billing.api.serializers import SubscriptionSerializer
-from smoothschedule.commerce.billing.models import AddOnProduct
-from smoothschedule.commerce.billing.models import Invoice
-from smoothschedule.commerce.billing.models import PlanVersion
-from smoothschedule.commerce.billing.services.entitlements import EntitlementService
-
-
-class EntitlementsView(APIView):
- """
- GET /api/me/entitlements/
-
- Returns the current business's effective entitlements.
- """
-
- permission_classes = [IsAuthenticated]
-
- def get(self, request):
- tenant = getattr(request.user, "tenant", None)
- if not tenant:
- return Response({})
-
- entitlements = EntitlementService.get_effective_entitlements(tenant)
- return Response(entitlements)
-
-
-class CurrentSubscriptionView(APIView):
- """
- GET /api/me/subscription/
-
- Returns the current business's subscription with plan version details.
- """
-
- permission_classes = [IsAuthenticated]
-
- def get(self, request):
- tenant = getattr(request.user, "tenant", None)
- if not tenant:
- return Response(
- {"detail": "No tenant context"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- subscription = getattr(tenant, "billing_subscription", None)
- if not subscription:
- return Response(
- {"detail": "No subscription found"},
- status=status.HTTP_404_NOT_FOUND,
- )
-
- serializer = SubscriptionSerializer(subscription)
- return Response(serializer.data)
-
-
-class PlanCatalogView(APIView):
- """
- GET /api/billing/plans/
-
- Returns public, non-legacy plan versions (the plan catalog).
- """
-
- # This endpoint is public - no authentication required
- # Allows visitors to see pricing before signup
-
- def get(self, request):
- # Filter for public, non-legacy plans
- plan_versions = (
- PlanVersion.objects.filter(is_public=True, is_legacy=False)
- .select_related("plan")
- .prefetch_related("features__feature")
- .order_by("plan__display_order", "plan__name", "-version")
- )
-
- # Filter by availability window (is_available property)
- available_versions = [pv for pv in plan_versions if pv.is_available]
-
- serializer = PlanVersionSerializer(available_versions, many=True)
- return Response(serializer.data)
-
-
-class AddOnCatalogView(APIView):
- """
- GET /api/billing/addons/
-
- Returns available add-on products.
- """
-
- permission_classes = [IsAuthenticated]
-
- def get(self, request):
- addons = AddOnProduct.objects.filter(is_active=True)
- serializer = AddOnProductSerializer(addons, many=True)
- return Response(serializer.data)
-
-
-class InvoiceListView(APIView):
- """
- GET /api/billing/invoices/
-
- Returns paginated invoice list for the current business.
- """
-
- permission_classes = [IsAuthenticated]
-
- def get(self, request):
- tenant = getattr(request.user, "tenant", None)
- if not tenant:
- return Response(
- {"detail": "No tenant context"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Tenant-isolated query
- invoices = Invoice.objects.filter(business=tenant).order_by("-created_at")
-
- # Simple pagination
- page_size = int(request.query_params.get("page_size", 20))
- page = int(request.query_params.get("page", 1))
- offset = (page - 1) * page_size
-
- total_count = invoices.count()
- invoices_page = invoices[offset : offset + page_size]
-
- serializer = InvoiceListSerializer(invoices_page, many=True)
- return Response(
- {
- "count": total_count,
- "page": page,
- "page_size": page_size,
- "results": serializer.data,
- }
- )
-
-
-class InvoiceDetailView(APIView):
- """
- GET /api/billing/invoices/{id}/
-
- Returns invoice detail with line items.
- """
-
- permission_classes = [IsAuthenticated]
-
- def get(self, request, invoice_id):
- tenant = getattr(request.user, "tenant", None)
- if not tenant:
- return Response(
- {"detail": "No tenant context"},
- status=status.HTTP_400_BAD_REQUEST,
- )
-
- # Tenant-isolated query - cannot see other tenant's invoices
- try:
- invoice = Invoice.objects.prefetch_related("lines").get(
- business=tenant, id=invoice_id
- )
- except Invoice.DoesNotExist:
- return Response(
- {"detail": "Invoice not found"},
- status=status.HTTP_404_NOT_FOUND,
- )
-
- serializer = InvoiceSerializer(invoice)
- return Response(serializer.data)
diff --git a/smoothschedule/smoothschedule/identity/core/models.py b/smoothschedule/smoothschedule/identity/core/models.py
index 859447d..7cbfd12 100644
--- a/smoothschedule/smoothschedule/identity/core/models.py
+++ b/smoothschedule/smoothschedule/identity/core/models.py
@@ -457,7 +457,7 @@ class Tenant(TenantMixin):
# Check new billing EntitlementService if billing_subscription exists
if hasattr(self, 'billing_subscription') and self.billing_subscription:
- from smoothschedule.commerce.billing.services.entitlements import (
+ from smoothschedule.billing.services.entitlements import (
EntitlementService,
)
return EntitlementService.has_feature(self, permission_key)
diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_models.py b/smoothschedule/smoothschedule/identity/users/tests/test_models.py
index 3ced6dc..d3b3be4 100644
--- a/smoothschedule/smoothschedule/identity/users/tests/test_models.py
+++ b/smoothschedule/smoothschedule/identity/users/tests/test_models.py
@@ -435,9 +435,12 @@ class TestSaveMethodValidation:
@pytest.mark.django_db
def test_sets_role_to_superuser_when_is_superuser_flag_set(self):
# Arrange - Test Django's create_superuser compatibility
+ import uuid
+ unique_id = str(uuid.uuid4())[:8]
+
user = User(
- username="admin",
- email="admin@example.com",
+ username=f"admin_models_{unique_id}",
+ email=f"admin_models_{unique_id}@example.com",
is_superuser=True,
role=User.Role.CUSTOMER # Wrong role, should be corrected
)
diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
index 7e6b524..063b770 100644
--- a/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
+++ b/smoothschedule/smoothschedule/identity/users/tests/test_user_model.py
@@ -595,9 +595,12 @@ class TestSaveMethodValidation:
@pytest.mark.django_db
def test_sets_role_to_superuser_when_is_superuser_flag_set(self):
# Test Django's create_superuser command compatibility
+ import uuid
+ unique_id = str(uuid.uuid4())[:8]
+
user = User(
- username='admin',
- email='admin@example.com',
+ username=f'admin_user_{unique_id}',
+ email=f'admin_user_{unique_id}@example.com',
is_superuser=True,
role=User.Role.CUSTOMER # Wrong role, should be corrected
)
diff --git a/smoothschedule/smoothschedule/platform/admin/management/commands/seed_subscription_plans.py b/smoothschedule/smoothschedule/platform/admin/management/commands/seed_subscription_plans.py
index 5283c31..607fa42 100644
--- a/smoothschedule/smoothschedule/platform/admin/management/commands/seed_subscription_plans.py
+++ b/smoothschedule/smoothschedule/platform/admin/management/commands/seed_subscription_plans.py
@@ -29,7 +29,6 @@ class Command(BaseCommand):
'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': [
@@ -78,7 +77,6 @@ class Command(BaseCommand):
'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': [
@@ -130,7 +128,6 @@ class Command(BaseCommand):
'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': [
@@ -189,7 +186,6 @@ class Command(BaseCommand):
'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': [
@@ -248,7 +244,6 @@ class Command(BaseCommand):
'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': [
@@ -308,7 +303,6 @@ class Command(BaseCommand):
'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': [
@@ -330,7 +324,6 @@ class Command(BaseCommand):
'name': 'SMS Notifications',
'description': 'Send SMS appointment reminders and notifications to your customers.',
'plan_type': 'addon',
- 'business_tier': '', # Available to any tier without SMS
'price_monthly': 10.00,
'price_yearly': 100.00,
'features': [
@@ -356,7 +349,6 @@ class Command(BaseCommand):
'name': 'SMS Bundle',
'description': 'Bulk SMS credits at a discounted rate. Requires SMS Notifications.',
'plan_type': 'addon',
- 'business_tier': '',
'price_monthly': 20.00,
'price_yearly': None,
'features': [
@@ -380,7 +372,6 @@ class Command(BaseCommand):
'name': 'Masked Calling',
'description': 'Enable anonymous phone calls between your customers and staff.',
'plan_type': 'addon',
- 'business_tier': '', # Available to any tier without masked calling
'price_monthly': 15.00,
'price_yearly': 150.00,
'features': [
@@ -409,7 +400,6 @@ class Command(BaseCommand):
'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': [
@@ -433,7 +423,6 @@ class Command(BaseCommand):
'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': [
@@ -457,7 +446,6 @@ class Command(BaseCommand):
'name': 'Online Payments',
'description': 'Accept online payments from your customers. For businesses on Free tier.',
'plan_type': 'addon',
- 'business_tier': '', # Available to any tier without payments
'price_monthly': 5.00,
'price_yearly': 50.00,
'features': [
diff --git a/smoothschedule/smoothschedule/platform/admin/migrations/0013_remove_business_tier_from_subscription_plan.py b/smoothschedule/smoothschedule/platform/admin/migrations/0013_remove_business_tier_from_subscription_plan.py
new file mode 100644
index 0000000..8f71728
--- /dev/null
+++ b/smoothschedule/smoothschedule/platform/admin/migrations/0013_remove_business_tier_from_subscription_plan.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.8 on 2025-12-12 06:09
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('platform_admin', '0012_add_contracts_enabled'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='subscriptionplan',
+ name='business_tier',
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/platform/admin/models.py b/smoothschedule/smoothschedule/platform/admin/models.py
index 6994b18..b87ffc4 100644
--- a/smoothschedule/smoothschedule/platform/admin/models.py
+++ b/smoothschedule/smoothschedule/platform/admin/models.py
@@ -205,21 +205,6 @@ class SubscriptionPlan(models.Model):
help_text="Yearly price in dollars"
)
- # Business tier this plan corresponds to (empty for addons)
- business_tier = models.CharField(
- max_length=50,
- choices=[
- ('', 'N/A (Add-on)'),
- ('Free', 'Free'),
- ('Starter', 'Starter'),
- ('Professional', 'Professional'),
- ('Business', 'Business'),
- ('Enterprise', 'Enterprise'),
- ],
- blank=True,
- default=''
- )
-
# Features included (stored as JSON array of strings)
features = models.JSONField(
default=list,
diff --git a/smoothschedule/smoothschedule/platform/admin/serializers.py b/smoothschedule/smoothschedule/platform/admin/serializers.py
index 5995718..a2f786c 100644
--- a/smoothschedule/smoothschedule/platform/admin/serializers.py
+++ b/smoothschedule/smoothschedule/platform/admin/serializers.py
@@ -108,7 +108,7 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
fields = [
'id', 'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
- 'price_monthly', 'price_yearly', 'business_tier',
+ 'price_monthly', 'price_yearly',
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
@@ -138,7 +138,7 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
fields = [
'name', 'description', 'plan_type',
'stripe_product_id', 'stripe_price_id',
- 'price_monthly', 'price_yearly', 'business_tier',
+ 'price_monthly', 'price_yearly',
'features', 'limits', 'permissions',
'transaction_fee_percent', 'transaction_fee_fixed',
# SMS & Communication Settings
diff --git a/smoothschedule/smoothschedule/platform/admin/tasks.py b/smoothschedule/smoothschedule/platform/admin/tasks.py
index 18c805a..9e9bd1e 100644
--- a/smoothschedule/smoothschedule/platform/admin/tasks.py
+++ b/smoothschedule/smoothschedule/platform/admin/tasks.py
@@ -311,19 +311,18 @@ def sync_subscription_plan_to_tenants(self, plan_id: int):
setattr(tenant, field, new_value)
changed = True
- # Update subscription tier if plan has a business_tier
- if plan.business_tier:
- tier_mapping = {
- 'Free': 'FREE',
- 'Starter': 'STARTER',
- 'Professional': 'PROFESSIONAL',
- 'Business': 'PROFESSIONAL', # Map Business to Professional
- 'Enterprise': 'ENTERPRISE',
- }
- new_tier = tier_mapping.get(plan.business_tier)
- if new_tier and tenant.subscription_tier != new_tier:
- tenant.subscription_tier = new_tier
- changed = True
+ # Update subscription tier based on plan name
+ tier_mapping = {
+ 'Free': 'FREE',
+ 'Starter': 'STARTER',
+ 'Professional': 'PROFESSIONAL',
+ 'Business': 'PROFESSIONAL', # Map Business to Professional
+ 'Enterprise': 'ENTERPRISE',
+ }
+ new_tier = tier_mapping.get(plan.name)
+ if new_tier and tenant.subscription_tier != new_tier:
+ tenant.subscription_tier = new_tier
+ changed = True
if changed:
tenant.save()
diff --git a/smoothschedule/smoothschedule/platform/admin/views.py b/smoothschedule/smoothschedule/platform/admin/views.py
index 2f27527..229c8f8 100644
--- a/smoothschedule/smoothschedule/platform/admin/views.py
+++ b/smoothschedule/smoothschedule/platform/admin/views.py
@@ -752,7 +752,6 @@ class SubscriptionPlanViewSet(viewsets.ModelViewSet):
metadata={
'plan_id': str(plan.id),
'plan_type': plan.plan_type,
- 'business_tier': plan.business_tier
}
)
plan.stripe_product_id = product.id
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py
new file mode 100644
index 0000000..39bd8dc
--- /dev/null
+++ b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/seed_data.py
@@ -0,0 +1,682 @@
+"""
+Comprehensive seed data management command.
+
+Creates all necessary data for development/testing:
+- Demo tenant with domain
+- Test users matching quick login (platform + tenant users)
+- Resource types
+- Resources (linked to staff users)
+- Services
+- Multiple customers
+- Appointments spanning the current month
+"""
+import random
+from datetime import timedelta
+from decimal import Decimal
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.management.base import BaseCommand
+from django.db import connection
+from django.utils import timezone
+from django_tenants.utils import schema_context, tenant_context
+
+from smoothschedule.identity.core.models import Tenant, Domain
+from smoothschedule.identity.users.models import User
+from smoothschedule.scheduling.schedule.models import (
+ Event,
+ Participant,
+ Resource,
+ ResourceType,
+ Service,
+)
+
+
+class Command(BaseCommand):
+ help = "Seed database with comprehensive demo data for development"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--clear",
+ action="store_true",
+ help="Clear existing data before seeding",
+ )
+ parser.add_argument(
+ "--appointments",
+ type=int,
+ default=75,
+ help="Number of appointments to create (default: 75)",
+ )
+
+ def handle(self, *args, **options):
+ self.stdout.write("\n" + "=" * 70)
+ self.stdout.write(self.style.SUCCESS(" SMOOTH SCHEDULE - SEED DATA"))
+ self.stdout.write("=" * 70 + "\n")
+
+ # Step 1: Create tenant and domain
+ demo_tenant = self.create_tenant()
+
+ # Step 2: Create platform users (in public schema)
+ self.create_platform_users()
+
+ # Step 3: Switch to tenant schema for tenant-specific data
+ with tenant_context(demo_tenant):
+ # Clear existing data if requested
+ if options["clear"]:
+ self.clear_existing_data()
+
+ # Step 4: Create tenant users
+ tenant_users = self.create_tenant_users(demo_tenant)
+
+ # Step 5: Create resource types
+ resource_types = self.create_resource_types()
+
+ # Step 6: Create services
+ services = self.create_services()
+
+ # Step 7: Create resources (including linking staff to resources)
+ resources = self.create_resources(tenant_users, resource_types)
+
+ # Step 8: Create additional customers
+ customers = self.create_customers(demo_tenant)
+
+ # Step 9: Create appointments
+ self.create_appointments(
+ resources=resources,
+ services=services,
+ customers=customers,
+ count=options["appointments"],
+ )
+
+ self.stdout.write("\n" + "=" * 70)
+ self.stdout.write(self.style.SUCCESS(" SEED DATA COMPLETE!"))
+ self.stdout.write("=" * 70)
+ self.stdout.write("\nQuick Login Credentials:")
+ self.stdout.write(" All passwords: test123")
+ self.stdout.write("\nAccess URLs:")
+ self.stdout.write(" Platform: http://platform.lvh.me:5173")
+ self.stdout.write(" Business: http://demo.lvh.me:5173\n")
+
+ def create_tenant(self):
+ """Create public tenant (for platform/API) and demo tenant with domains."""
+ self.stdout.write("\n[1/9] Creating Tenants and Domains...")
+
+ # First create the public tenant (for platform users and API)
+ try:
+ public_tenant = Tenant.objects.get(schema_name="public")
+ self.stdout.write(f" {self.style.WARNING('EXISTS')} Public tenant already exists")
+ except Tenant.DoesNotExist:
+ public_tenant = Tenant.objects.create(
+ schema_name="public",
+ name="Platform",
+ # Note: subscription_tier is just a label - actual plans are created via Platform Settings UI
+ max_users=999,
+ max_resources=999,
+ )
+ self.stdout.write(f" {self.style.SUCCESS('CREATED')} Public tenant (Platform)")
+
+ # Create domains for public tenant (platform, api, and root)
+ public_domains = [
+ ("platform.lvh.me", True), # Primary for platform
+ ("api.lvh.me", False), # API subdomain
+ ("lvh.me", False), # Root domain
+ ]
+ for domain_name, is_primary in public_domains:
+ domain, created = Domain.objects.get_or_create(
+ domain=domain_name,
+ defaults={
+ "tenant": public_tenant,
+ "is_primary": is_primary,
+ },
+ )
+ if created:
+ self.stdout.write(f" {self.style.SUCCESS('CREATED')} Domain: {domain_name}")
+ else:
+ self.stdout.write(f" {self.style.WARNING('EXISTS')} Domain: {domain_name}")
+
+ # Now create the demo tenant
+ try:
+ tenant = Tenant.objects.get(schema_name="demo")
+ self.stdout.write(f" {self.style.WARNING('EXISTS')} Demo tenant already exists")
+ except Tenant.DoesNotExist:
+ tenant = Tenant.objects.create(
+ schema_name="demo",
+ name="Demo Company",
+ # Note: No subscription_tier set - plans are created via Platform Settings UI
+ # These feature flags are set directly for demo purposes
+ max_users=25,
+ max_resources=50,
+ timezone="America/Denver",
+ can_use_plugins=True,
+ can_use_tasks=True,
+ can_accept_payments=True,
+ can_customize_booking_page=True,
+ initial_setup_complete=True,
+ )
+ self.stdout.write(f" {self.style.SUCCESS('CREATED')} Demo tenant")
+
+ # Create domain for demo tenant
+ domain, created = Domain.objects.get_or_create(
+ domain="demo.lvh.me",
+ defaults={
+ "tenant": tenant,
+ "is_primary": True,
+ },
+ )
+ if created:
+ self.stdout.write(f" {self.style.SUCCESS('CREATED')} Domain: demo.lvh.me")
+ else:
+ self.stdout.write(f" {self.style.WARNING('EXISTS')} Domain: demo.lvh.me")
+
+ return tenant
+
+ def create_platform_users(self):
+ """Create platform-level users (superuser, manager, sales, support)."""
+ self.stdout.write("\n[2/9] Creating Platform Users...")
+
+ platform_users = [
+ {
+ "username": "poduck",
+ "email": "poduck@gmail.com",
+ "password": "starry12",
+ "role": User.Role.SUPERUSER,
+ "first_name": "Poduck",
+ "last_name": "Admin",
+ "tenant": None,
+ },
+ {
+ "username": "superuser@platform.com",
+ "email": "superuser@platform.com",
+ "password": "test123",
+ "role": User.Role.SUPERUSER,
+ "first_name": "Super",
+ "last_name": "User",
+ "tenant": None,
+ },
+ {
+ "username": "manager@platform.com",
+ "email": "manager@platform.com",
+ "password": "test123",
+ "role": User.Role.PLATFORM_MANAGER,
+ "first_name": "Platform",
+ "last_name": "Manager",
+ "tenant": None,
+ },
+ {
+ "username": "sales@platform.com",
+ "email": "sales@platform.com",
+ "password": "test123",
+ "role": User.Role.PLATFORM_SALES,
+ "first_name": "Sales",
+ "last_name": "Rep",
+ "tenant": None,
+ },
+ {
+ "username": "support@platform.com",
+ "email": "support@platform.com",
+ "password": "test123",
+ "role": User.Role.PLATFORM_SUPPORT,
+ "first_name": "Support",
+ "last_name": "Agent",
+ "tenant": None,
+ },
+ ]
+
+ for user_data in platform_users:
+ password = user_data.pop("password")
+ user, created = User.objects.get_or_create(
+ username=user_data["username"],
+ defaults=user_data,
+ )
+ if created:
+ user.set_password(password)
+ user.save()
+ status = self.style.SUCCESS("CREATED")
+ else:
+ status = self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
+
+ def create_tenant_users(self, tenant):
+ """Create tenant-level users (owner, manager, staff)."""
+ self.stdout.write("\n[3/9] Creating Tenant Users...")
+
+ tenant_users = [
+ {
+ "username": "owner@demo.com",
+ "email": "owner@demo.com",
+ "password": "test123",
+ "role": User.Role.TENANT_OWNER,
+ "first_name": "Business",
+ "last_name": "Owner",
+ "tenant": tenant,
+ "phone": "555-100-0001",
+ },
+ {
+ "username": "manager@demo.com",
+ "email": "manager@demo.com",
+ "password": "test123",
+ "role": User.Role.TENANT_MANAGER,
+ "first_name": "Business",
+ "last_name": "Manager",
+ "tenant": tenant,
+ "phone": "555-100-0002",
+ },
+ {
+ "username": "staff@demo.com",
+ "email": "staff@demo.com",
+ "password": "test123",
+ "role": User.Role.TENANT_STAFF,
+ "first_name": "Staff",
+ "last_name": "Member",
+ "tenant": tenant,
+ "phone": "555-100-0003",
+ },
+ ]
+
+ created_users = {}
+ for user_data in tenant_users:
+ password = user_data.pop("password")
+ user, created = User.objects.get_or_create(
+ username=user_data["username"],
+ defaults=user_data,
+ )
+ if created:
+ user.set_password(password)
+ user.save()
+ status = self.style.SUCCESS("CREATED")
+ else:
+ status = self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {user.email} ({user.get_role_display()})")
+ created_users[user_data["role"]] = user
+
+ return created_users
+
+ def create_resource_types(self):
+ """Create resource types."""
+ self.stdout.write("\n[4/9] Creating Resource Types...")
+
+ resource_types_data = [
+ {
+ "name": "Staff",
+ "category": ResourceType.Category.STAFF,
+ "description": "Staff members who provide services",
+ "is_default": True,
+ },
+ {
+ "name": "Room",
+ "category": ResourceType.Category.OTHER,
+ "description": "Treatment or meeting rooms",
+ "is_default": True,
+ },
+ {
+ "name": "Equipment",
+ "category": ResourceType.Category.OTHER,
+ "description": "Shared equipment",
+ "is_default": False,
+ },
+ ]
+
+ resource_types = {}
+ for rt_data in resource_types_data:
+ rt, created = ResourceType.objects.get_or_create(
+ name=rt_data["name"],
+ defaults=rt_data,
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {rt.name} ({rt.get_category_display()})")
+ resource_types[rt_data["name"]] = rt
+
+ return resource_types
+
+ def create_services(self):
+ """Create services."""
+ self.stdout.write("\n[5/9] Creating Services...")
+
+ services_data = [
+ {
+ "name": "Consultation",
+ "description": "Initial consultation to discuss your needs",
+ "duration": 30,
+ "price_cents": 0,
+ "display_order": 1,
+ },
+ {
+ "name": "Standard Appointment",
+ "description": "Standard 1-hour appointment",
+ "duration": 60,
+ "price_cents": 7500, # $75.00
+ "display_order": 2,
+ },
+ {
+ "name": "Extended Session",
+ "description": "Extended 90-minute session",
+ "duration": 90,
+ "price_cents": 11000, # $110.00
+ "display_order": 3,
+ },
+ {
+ "name": "Quick Check-in",
+ "description": "Brief 15-minute check-in",
+ "duration": 15,
+ "price_cents": 2500, # $25.00
+ "display_order": 4,
+ },
+ {
+ "name": "Premium Package",
+ "description": "Premium 2-hour comprehensive service",
+ "duration": 120,
+ "price_cents": 20000, # $200.00
+ "display_order": 5,
+ "variable_pricing": True,
+ "deposit_amount_cents": 5000, # $50 deposit
+ },
+ ]
+
+ services = []
+ for svc_data in services_data:
+ service, created = Service.objects.get_or_create(
+ name=svc_data["name"],
+ defaults=svc_data,
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ price = svc_data["price_cents"] / 100
+ self.stdout.write(f" {status} {service.name} ({svc_data['duration']} min, ${price:.2f})")
+ services.append(service)
+
+ return services
+
+ def create_resources(self, tenant_users, resource_types):
+ """Create resources including staff-linked resources."""
+ self.stdout.write("\n[6/9] Creating Resources...")
+
+ staff_type = resource_types.get("Staff")
+ room_type = resource_types.get("Room")
+ equipment_type = resource_types.get("Equipment")
+
+ resources = []
+
+ # Create staff resources linked to users
+ staff_resources = [
+ {
+ "name": "Staff Member",
+ "user": tenant_users.get(User.Role.TENANT_STAFF),
+ "description": "General staff member",
+ "resource_type": staff_type,
+ "type": Resource.Type.STAFF,
+ },
+ {
+ "name": "Business Manager",
+ "user": tenant_users.get(User.Role.TENANT_MANAGER),
+ "description": "Business manager - handles VIP appointments",
+ "resource_type": staff_type,
+ "type": Resource.Type.STAFF,
+ },
+ ]
+
+ for res_data in staff_resources:
+ if res_data["user"]:
+ resource, created = Resource.objects.get_or_create(
+ user=res_data["user"],
+ defaults=res_data,
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {resource.name} (Staff - linked to {res_data['user'].email})")
+ resources.append(resource)
+
+ # Create additional staff resources (not linked to quick login users)
+ additional_staff = [
+ {"name": "Sarah Johnson", "description": "Senior specialist"},
+ {"name": "Mike Chen", "description": "Team lead"},
+ {"name": "Emily Rodriguez", "description": "Junior specialist"},
+ ]
+
+ for staff_data in additional_staff:
+ # Create a user for this staff member
+ email = staff_data["name"].lower().replace(" ", ".") + "@demo.com"
+ first_name, last_name = staff_data["name"].split(" ", 1)
+
+ user, _ = User.objects.get_or_create(
+ username=email,
+ defaults={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "role": User.Role.TENANT_STAFF,
+ "tenant_id": connection.tenant.id if hasattr(connection, "tenant") else None,
+ },
+ )
+ if user.pk:
+ user.set_password("test123")
+ user.save()
+
+ resource, created = Resource.objects.get_or_create(
+ name=staff_data["name"],
+ defaults={
+ "user": user,
+ "description": staff_data["description"],
+ "resource_type": staff_type,
+ "type": Resource.Type.STAFF,
+ },
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {resource.name} (Staff)")
+ resources.append(resource)
+
+ # Create room resources
+ room_resources = [
+ {"name": "Room A", "description": "Main meeting room", "max_concurrent_events": 1},
+ {"name": "Room B", "description": "Private consultation room", "max_concurrent_events": 1},
+ {"name": "Conference Room", "description": "Large conference room", "max_concurrent_events": 3},
+ ]
+
+ for room_data in room_resources:
+ resource, created = Resource.objects.get_or_create(
+ name=room_data["name"],
+ defaults={
+ "description": room_data["description"],
+ "resource_type": room_type,
+ "type": Resource.Type.ROOM,
+ "max_concurrent_events": room_data.get("max_concurrent_events", 1),
+ },
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {resource.name} (Room)")
+ resources.append(resource)
+
+ # Create equipment resource
+ equipment, created = Resource.objects.get_or_create(
+ name="Projector",
+ defaults={
+ "description": "Portable projector for presentations",
+ "resource_type": equipment_type,
+ "type": Resource.Type.EQUIPMENT,
+ "max_concurrent_events": 1,
+ },
+ )
+ status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {equipment.name} (Equipment)")
+ resources.append(equipment)
+
+ return resources
+
+ def create_customers(self, tenant):
+ """Create customer users."""
+ self.stdout.write("\n[7/9] Creating Customers...")
+
+ # First add the quick login customer
+ customer_demo, created = User.objects.get_or_create(
+ username="customer@demo.com",
+ defaults={
+ "email": "customer@demo.com",
+ "first_name": "Demo",
+ "last_name": "Customer",
+ "role": User.Role.CUSTOMER,
+ "tenant": tenant,
+ "phone": "555-200-0001",
+ },
+ )
+ if created:
+ customer_demo.set_password("test123")
+ customer_demo.save()
+ status = self.style.SUCCESS("CREATED")
+ else:
+ status = self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {customer_demo.email} (Quick Login Customer)")
+
+ customers = [customer_demo]
+
+ # Create additional customers
+ customer_data = [
+ ("Alice", "Williams", "alice.williams@example.com", "555-200-0002"),
+ ("Bob", "Martinez", "bob.martinez@example.com", "555-200-0003"),
+ ("Carol", "Davis", "carol.davis@example.com", "555-200-0004"),
+ ("David", "Lee", "david.lee@example.com", "555-200-0005"),
+ ("Emma", "Thompson", "emma.thompson@example.com", "555-200-0006"),
+ ("Frank", "Wilson", "frank.wilson@example.com", "555-200-0007"),
+ ("Grace", "Kim", "grace.kim@example.com", "555-200-0008"),
+ ("Henry", "Brown", "henry.brown@example.com", "555-200-0009"),
+ ("Ivy", "Chen", "ivy.chen@example.com", "555-200-0010"),
+ ("Jack", "Taylor", "jack.taylor@example.com", "555-200-0011"),
+ ("Karen", "Johnson", "karen.johnson@example.com", "555-200-0012"),
+ ("Leo", "Garcia", "leo.garcia@example.com", "555-200-0013"),
+ ("Maria", "Rodriguez", "maria.rodriguez@example.com", "555-200-0014"),
+ ("Nathan", "White", "nathan.white@example.com", "555-200-0015"),
+ ]
+
+ for first_name, last_name, email, phone in customer_data:
+ user, created = User.objects.get_or_create(
+ username=email,
+ defaults={
+ "email": email,
+ "first_name": first_name,
+ "last_name": last_name,
+ "role": User.Role.CUSTOMER,
+ "tenant": tenant,
+ "phone": phone,
+ },
+ )
+ if created:
+ user.set_password("test123")
+ user.save()
+ status = self.style.SUCCESS("CREATED")
+ else:
+ status = self.style.WARNING("EXISTS")
+ self.stdout.write(f" {status} {user.email}")
+ customers.append(user)
+
+ return customers
+
+ def create_appointments(self, resources, services, customers, count):
+ """Create demo appointments."""
+ self.stdout.write(f"\n[8/9] Creating {count} Appointments...")
+
+ # Filter to only staff resources for appointments
+ staff_resources = [r for r in resources if r.type == Resource.Type.STAFF]
+ if not staff_resources:
+ staff_resources = resources[:3] # Fallback
+
+ # Get content types
+ resource_ct = ContentType.objects.get_for_model(Resource)
+ user_ct = ContentType.objects.get_for_model(User)
+
+ # Get time range (current month + next month)
+ now = timezone.now()
+ start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
+ if now.month == 12:
+ end_date = start_date.replace(year=now.year + 1, month=2, day=1)
+ elif now.month == 11:
+ end_date = start_date.replace(year=now.year + 1, month=1, day=1)
+ else:
+ end_date = start_date.replace(month=now.month + 2, day=1)
+
+ days_range = (end_date - start_date).days
+
+ statuses = [
+ Event.Status.SCHEDULED,
+ Event.Status.SCHEDULED,
+ Event.Status.SCHEDULED,
+ Event.Status.COMPLETED,
+ Event.Status.COMPLETED,
+ Event.Status.CANCELED,
+ ]
+
+ created_count = 0
+ for i in range(count):
+ # Random date in range
+ random_day = random.randint(0, days_range - 1)
+ appointment_date = start_date + timedelta(days=random_day)
+
+ # Random business hours (8am - 6pm)
+ hour = random.randint(8, 17)
+ minute = random.choice([0, 15, 30, 45])
+ start_time = appointment_date.replace(hour=hour, minute=minute)
+
+ # Pick random service, resource, customer
+ service = random.choice(services)
+ resource = random.choice(staff_resources)
+ customer = random.choice(customers)
+
+ # Determine status
+ status = random.choice(statuses)
+ if start_time < now and status == Event.Status.SCHEDULED:
+ status = Event.Status.COMPLETED
+ elif start_time > now and status in [Event.Status.COMPLETED, Event.Status.CANCELED]:
+ status = Event.Status.SCHEDULED
+
+ # Calculate end time
+ end_time = start_time + timedelta(minutes=service.duration)
+
+ # Create event
+ event = Event.objects.create(
+ title=f"{customer.full_name} - {service.name}",
+ start_time=start_time,
+ end_time=end_time,
+ status=status,
+ service=service,
+ notes=f"Booked service: {service.name}\nCustomer phone: {customer.phone}",
+ )
+
+ # Create resource participant
+ Participant.objects.create(
+ event=event,
+ role=Participant.Role.RESOURCE,
+ content_type=resource_ct,
+ object_id=resource.id,
+ )
+
+ # Create customer participant
+ Participant.objects.create(
+ event=event,
+ role=Participant.Role.CUSTOMER,
+ content_type=user_ct,
+ object_id=customer.id,
+ )
+
+ created_count += 1
+
+ self.stdout.write(
+ f" {self.style.SUCCESS('CREATED')} {created_count} appointments across {days_range} days"
+ )
+
+ # Show summary
+ self.stdout.write("\n[9/9] Summary Statistics...")
+ scheduled = Event.objects.filter(status=Event.Status.SCHEDULED).count()
+ completed = Event.objects.filter(status=Event.Status.COMPLETED).count()
+ canceled = Event.objects.filter(status=Event.Status.CANCELED).count()
+
+ self.stdout.write(f" Scheduled: {scheduled}")
+ self.stdout.write(f" Completed: {completed}")
+ self.stdout.write(f" Canceled: {canceled}")
+
+ def clear_existing_data(self):
+ """Clear existing demo data."""
+ self.stdout.write("\n Clearing existing data...")
+
+ # Delete in order to respect foreign keys
+ deleted = Participant.objects.all().delete()[0]
+ self.stdout.write(f" Deleted {deleted} participants")
+
+ deleted = Event.objects.all().delete()[0]
+ self.stdout.write(f" Deleted {deleted} events")
+
+ # Don't delete resources/services/customers - just events
+ self.stdout.write(" (Keeping resources, services, and customers)")