From 023ea7f020f971d56a53fe39ff99278b7165ae9a Mon Sep 17 00:00:00 2001 From: poduck Date: Fri, 5 Dec 2025 23:28:51 -0500 Subject: [PATCH] feat(contracts): Add contracts permission to subscription tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add contracts_enabled field to SubscriptionPlan model - Add contracts toggle to plan create/edit modal in platform settings - Hide contracts menu item for tenants without contracts permission - Protect /contracts routes with canUse('contracts') check - Add HasContractsPermission to contracts API ViewSets - Add contracts to PlanPermissions interface and feature definitions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.tsx | 6 ++- frontend/src/components/Sidebar.tsx | 14 ++++--- frontend/src/hooks/useBusiness.ts | 2 + frontend/src/hooks/usePlanFeatures.ts | 2 + frontend/src/hooks/usePlatformSettings.ts | 4 ++ .../src/pages/platform/PlatformSettings.tsx | 22 +++++++++++ frontend/src/types.ts | 1 + smoothschedule/contracts/views.py | 38 ++++++++++++++++--- .../migrations/0012_add_contracts_enabled.py | 18 +++++++++ smoothschedule/platform_admin/models.py | 6 +++ smoothschedule/platform_admin/serializers.py | 4 ++ smoothschedule/schedule/api_views.py | 1 + 12 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 smoothschedule/platform_admin/migrations/0012_add_contracts_enabled.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf169ec..7e985cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth'; import { useCurrentBusiness } from './hooks/useBusiness'; import { useUpdateBusiness } from './hooks/useBusiness'; +import { usePlanFeatures } from './hooks/usePlanFeatures'; import { setCookie } from './utils/cookies'; // Import Login Page @@ -192,6 +193,7 @@ const AppContent: React.FC = () => { const updateBusinessMutation = useUpdateBusiness(); const masqueradeMutation = useMasquerade(); const logoutMutation = useLogout(); + const { canUse } = usePlanFeatures(); // Apply dark mode class and persist to localStorage React.useEffect(() => { @@ -810,7 +812,7 @@ const AppContent: React.FC = () => { ) : ( @@ -820,7 +822,7 @@ const AppContent: React.FC = () => { ) : ( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ef8bcd8..838ca75 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -162,12 +162,14 @@ const Sidebar: React.FC = ({ business, user, isCollapsed, toggleCo label={t('nav.staff')} isCollapsed={isCollapsed} /> - + {canUse('contracts') && ( + + )} { white_label: false, custom_oauth: false, plugins: false, + tasks: false, export_data: false, video_conferencing: false, two_factor_auth: false, masked_calling: false, pos_system: false, mobile_app: false, + contracts: false, }, }; }, diff --git a/frontend/src/hooks/usePlanFeatures.ts b/frontend/src/hooks/usePlanFeatures.ts index 4b35229..c6f724b 100644 --- a/frontend/src/hooks/usePlanFeatures.ts +++ b/frontend/src/hooks/usePlanFeatures.ts @@ -91,6 +91,7 @@ export const FEATURE_NAMES: Record = { masked_calling: 'Masked Calling', pos_system: 'POS System', mobile_app: 'Mobile App', + contracts: 'Contracts', }; /** @@ -111,4 +112,5 @@ export const FEATURE_DESCRIPTIONS: Record = { masked_calling: 'Use masked phone numbers to protect privacy', pos_system: 'Process in-person payments with Point of Sale', mobile_app: 'Access SmoothSchedule on mobile devices', + contracts: 'Create and manage contracts with customers', }; diff --git a/frontend/src/hooks/usePlatformSettings.ts b/frontend/src/hooks/usePlatformSettings.ts index 6489f60..ee79d1a 100644 --- a/frontend/src/hooks/usePlatformSettings.ts +++ b/frontend/src/hooks/usePlatformSettings.ts @@ -51,6 +51,8 @@ export interface SubscriptionPlan { masked_calling_price_per_minute_cents: number; proxy_number_enabled: boolean; proxy_number_monthly_fee_cents: number; + // Contracts feature + contracts_enabled: boolean; // Default credit settings default_auto_reload_enabled: boolean; default_auto_reload_threshold_cents: number; @@ -82,6 +84,8 @@ export interface SubscriptionPlanCreate { masked_calling_price_per_minute_cents?: number; proxy_number_enabled?: boolean; proxy_number_monthly_fee_cents?: number; + // Contracts feature + contracts_enabled?: boolean; // Default credit settings default_auto_reload_enabled?: boolean; default_auto_reload_threshold_cents?: number; diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index b74fe23..821fd79 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -523,6 +523,7 @@ const StripeSettingsTab: React.FC = () => { }; const TiersSettingsTab: React.FC = () => { + const { t } = useTranslation(); const { data: plans, isLoading, error } = useSubscriptionPlans(); const createPlanMutation = useCreateSubscriptionPlan(); const updatePlanMutation = useUpdateSubscriptionPlan(); @@ -864,6 +865,8 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5, proxy_number_enabled: plan?.proxy_number_enabled ?? false, proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200, + // Contracts feature + contracts_enabled: plan?.contracts_enabled ?? false, // Default credit settings default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false, default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000, @@ -1251,6 +1254,25 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading )} + {/* Contracts Feature */} +
+
+
+

Contracts

+

Allow tenants to create and manage contracts with customers

+
+ +
+
+ {/* Default Credit Settings */}

Default Credit Settings

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 009da10..0dc8ce3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -46,6 +46,7 @@ export interface PlanPermissions { masked_calling: boolean; pos_system: boolean; mobile_app: boolean; + contracts: boolean; } export interface Business { diff --git a/smoothschedule/contracts/views.py b/smoothschedule/contracts/views.py index b9b4123..4c52554 100644 --- a/smoothschedule/contracts/views.py +++ b/smoothschedule/contracts/views.py @@ -9,9 +9,37 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated, AllowAny, BasePermission from .models import ContractTemplate, ServiceContractRequirement, Contract, ContractSignature + + +class HasContractsPermission(BasePermission): + """ + Permission class to check if the tenant has contracts feature enabled. + """ + message = "Contracts feature is not enabled for your subscription plan." + + def has_permission(self, request, view): + user = request.user + if not user or not user.is_authenticated: + return False + + # Platform users (superuser, platform_manager, etc.) can access + if hasattr(user, 'role') and user.role in ['superuser', 'platform_manager', 'platform_support']: + return True + + # Get tenant from user + tenant = getattr(user, 'tenant', None) + if not tenant: + return False + + # Check if tenant's subscription plan has contracts enabled + subscription_plan = getattr(tenant, 'subscription_plan', None) + if subscription_plan: + return getattr(subscription_plan, 'contracts_enabled', False) + + return False from .serializers import ( ContractTemplateSerializer, ContractTemplateListSerializer, ServiceContractRequirementSerializer, ContractSerializer, ContractListSerializer, @@ -32,10 +60,10 @@ def get_client_ip(request): class ContractTemplateViewSet(viewsets.ModelViewSet): """ CRUD for contract templates. - Permissions: owner/manager only + Permissions: owner/manager only, must have contracts feature enabled """ queryset = ContractTemplate.objects.all() - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasContractsPermission] def get_serializer_class(self): if self.action == "list": @@ -136,7 +164,7 @@ class ServiceContractRequirementViewSet(viewsets.ModelViewSet): """Manage which contracts are required for which services""" queryset = ServiceContractRequirement.objects.all() serializer_class = ServiceContractRequirementSerializer - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasContractsPermission] def get_queryset(self): qs = super().get_queryset().select_related("service", "template") @@ -155,7 +183,7 @@ class ContractViewSet(viewsets.ModelViewSet): Includes sending, viewing, and PDF download. """ queryset = Contract.objects.all() - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, HasContractsPermission] def get_serializer_class(self): if self.action == "list": diff --git a/smoothschedule/platform_admin/migrations/0012_add_contracts_enabled.py b/smoothschedule/platform_admin/migrations/0012_add_contracts_enabled.py new file mode 100644 index 0000000..f414102 --- /dev/null +++ b/smoothschedule/platform_admin/migrations/0012_add_contracts_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-06 03:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('platform_admin', '0011_update_subscription_plan_business_tier'), + ] + + operations = [ + migrations.AddField( + model_name='subscriptionplan', + name='contracts_enabled', + field=models.BooleanField(default=False, help_text='Whether tenants can use the contracts feature'), + ), + ] diff --git a/smoothschedule/platform_admin/models.py b/smoothschedule/platform_admin/models.py index f87d93e..6994b18 100644 --- a/smoothschedule/platform_admin/models.py +++ b/smoothschedule/platform_admin/models.py @@ -285,6 +285,12 @@ class SubscriptionPlan(models.Model): help_text="Monthly fee per proxy number in cents" ) + # Contracts Feature + contracts_enabled = models.BooleanField( + default=False, + help_text="Whether tenants can use the contracts feature" + ) + # Default Credit Settings (for new tenants on this tier) default_auto_reload_enabled = models.BooleanField( default=False, diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py index 4630dd7..9efdc30 100644 --- a/smoothschedule/platform_admin/serializers.py +++ b/smoothschedule/platform_admin/serializers.py @@ -117,6 +117,8 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer): 'masked_calling_enabled', 'masked_calling_price_per_minute_cents', # Proxy Number Settings 'proxy_number_enabled', 'proxy_number_monthly_fee_cents', + # Contracts Feature + 'contracts_enabled', # Default Credit Settings 'default_auto_reload_enabled', 'default_auto_reload_threshold_cents', 'default_auto_reload_amount_cents', @@ -145,6 +147,8 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer): 'masked_calling_enabled', 'masked_calling_price_per_minute_cents', # Proxy Number Settings 'proxy_number_enabled', 'proxy_number_monthly_fee_cents', + # Contracts Feature + 'contracts_enabled', # Default Credit Settings 'default_auto_reload_enabled', 'default_auto_reload_threshold_cents', 'default_auto_reload_amount_cents', diff --git a/smoothschedule/schedule/api_views.py b/smoothschedule/schedule/api_views.py index c26d2b2..e5f8332 100644 --- a/smoothschedule/schedule/api_views.py +++ b/smoothschedule/schedule/api_views.py @@ -184,6 +184,7 @@ def current_business_view(request): 'masked_calling': tenant.can_use_masked_phone_numbers or plan_permissions.get('masked_calling', False), 'pos_system': tenant.can_use_pos or plan_permissions.get('pos_system', False), 'mobile_app': tenant.can_use_mobile_app or plan_permissions.get('mobile_app', False), + 'contracts': getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False, } business_data = {