feat(contracts): Add contracts permission to subscription tiers
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||||
|
import { usePlanFeatures } from './hooks/usePlanFeatures';
|
||||||
import { setCookie } from './utils/cookies';
|
import { setCookie } from './utils/cookies';
|
||||||
|
|
||||||
// Import Login Page
|
// Import Login Page
|
||||||
@@ -192,6 +193,7 @@ const AppContent: React.FC = () => {
|
|||||||
const updateBusinessMutation = useUpdateBusiness();
|
const updateBusinessMutation = useUpdateBusiness();
|
||||||
const masqueradeMutation = useMasquerade();
|
const masqueradeMutation = useMasquerade();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
|
const { canUse } = usePlanFeatures();
|
||||||
|
|
||||||
// Apply dark mode class and persist to localStorage
|
// Apply dark mode class and persist to localStorage
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -810,7 +812,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/contracts"
|
path="/contracts"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||||
<Contracts />
|
<Contracts />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/" />
|
||||||
@@ -820,7 +822,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/contracts/templates"
|
path="/contracts/templates"
|
||||||
element={
|
element={
|
||||||
hasAccess(['owner', 'manager']) ? (
|
hasAccess(['owner', 'manager']) && canUse('contracts') ? (
|
||||||
<ContractTemplates />
|
<ContractTemplates />
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/" />
|
<Navigate to="/" />
|
||||||
|
|||||||
@@ -162,12 +162,14 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
label={t('nav.staff')}
|
label={t('nav.staff')}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
/>
|
/>
|
||||||
<SidebarItem
|
{canUse('contracts') && (
|
||||||
to="/contracts"
|
<SidebarItem
|
||||||
icon={FileSignature}
|
to="/contracts"
|
||||||
label={t('nav.contracts', 'Contracts')}
|
icon={FileSignature}
|
||||||
isCollapsed={isCollapsed}
|
label={t('nav.contracts', 'Contracts')}
|
||||||
/>
|
isCollapsed={isCollapsed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="/time-blocks"
|
to="/time-blocks"
|
||||||
icon={CalendarOff}
|
icon={CalendarOff}
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ export const useCurrentBusiness = () => {
|
|||||||
white_label: false,
|
white_label: false,
|
||||||
custom_oauth: false,
|
custom_oauth: false,
|
||||||
plugins: false,
|
plugins: false,
|
||||||
|
tasks: false,
|
||||||
export_data: false,
|
export_data: false,
|
||||||
video_conferencing: false,
|
video_conferencing: false,
|
||||||
two_factor_auth: false,
|
two_factor_auth: false,
|
||||||
masked_calling: false,
|
masked_calling: false,
|
||||||
pos_system: false,
|
pos_system: false,
|
||||||
mobile_app: false,
|
mobile_app: false,
|
||||||
|
contracts: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
|||||||
masked_calling: 'Masked Calling',
|
masked_calling: 'Masked Calling',
|
||||||
pos_system: 'POS System',
|
pos_system: 'POS System',
|
||||||
mobile_app: 'Mobile App',
|
mobile_app: 'Mobile App',
|
||||||
|
contracts: 'Contracts',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,4 +112,5 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
|||||||
masked_calling: 'Use masked phone numbers to protect privacy',
|
masked_calling: 'Use masked phone numbers to protect privacy',
|
||||||
pos_system: 'Process in-person payments with Point of Sale',
|
pos_system: 'Process in-person payments with Point of Sale',
|
||||||
mobile_app: 'Access SmoothSchedule on mobile devices',
|
mobile_app: 'Access SmoothSchedule on mobile devices',
|
||||||
|
contracts: 'Create and manage contracts with customers',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface SubscriptionPlan {
|
|||||||
masked_calling_price_per_minute_cents: number;
|
masked_calling_price_per_minute_cents: number;
|
||||||
proxy_number_enabled: boolean;
|
proxy_number_enabled: boolean;
|
||||||
proxy_number_monthly_fee_cents: number;
|
proxy_number_monthly_fee_cents: number;
|
||||||
|
// Contracts feature
|
||||||
|
contracts_enabled: boolean;
|
||||||
// Default credit settings
|
// Default credit settings
|
||||||
default_auto_reload_enabled: boolean;
|
default_auto_reload_enabled: boolean;
|
||||||
default_auto_reload_threshold_cents: number;
|
default_auto_reload_threshold_cents: number;
|
||||||
@@ -82,6 +84,8 @@ export interface SubscriptionPlanCreate {
|
|||||||
masked_calling_price_per_minute_cents?: number;
|
masked_calling_price_per_minute_cents?: number;
|
||||||
proxy_number_enabled?: boolean;
|
proxy_number_enabled?: boolean;
|
||||||
proxy_number_monthly_fee_cents?: number;
|
proxy_number_monthly_fee_cents?: number;
|
||||||
|
// Contracts feature
|
||||||
|
contracts_enabled?: boolean;
|
||||||
// Default credit settings
|
// Default credit settings
|
||||||
default_auto_reload_enabled?: boolean;
|
default_auto_reload_enabled?: boolean;
|
||||||
default_auto_reload_threshold_cents?: number;
|
default_auto_reload_threshold_cents?: number;
|
||||||
|
|||||||
@@ -523,6 +523,7 @@ const StripeSettingsTab: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TiersSettingsTab: React.FC = () => {
|
const TiersSettingsTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: plans, isLoading, error } = useSubscriptionPlans();
|
const { data: plans, isLoading, error } = useSubscriptionPlans();
|
||||||
const createPlanMutation = useCreateSubscriptionPlan();
|
const createPlanMutation = useCreateSubscriptionPlan();
|
||||||
const updatePlanMutation = useUpdateSubscriptionPlan();
|
const updatePlanMutation = useUpdateSubscriptionPlan();
|
||||||
@@ -864,6 +865,8 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
|||||||
masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5,
|
masked_calling_price_per_minute_cents: plan?.masked_calling_price_per_minute_cents ?? 5,
|
||||||
proxy_number_enabled: plan?.proxy_number_enabled ?? false,
|
proxy_number_enabled: plan?.proxy_number_enabled ?? false,
|
||||||
proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200,
|
proxy_number_monthly_fee_cents: plan?.proxy_number_monthly_fee_cents ?? 200,
|
||||||
|
// Contracts feature
|
||||||
|
contracts_enabled: plan?.contracts_enabled ?? false,
|
||||||
// Default credit settings
|
// Default credit settings
|
||||||
default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false,
|
default_auto_reload_enabled: plan?.default_auto_reload_enabled ?? false,
|
||||||
default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000,
|
default_auto_reload_threshold_cents: plan?.default_auto_reload_threshold_cents ?? 1000,
|
||||||
@@ -1251,6 +1254,25 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Contracts Feature */}
|
||||||
|
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Contracts</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Allow tenants to create and manage contracts with customers</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.contracts_enabled || false}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, contracts_enabled: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Default Credit Settings */}
|
{/* Default Credit Settings */}
|
||||||
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface PlanPermissions {
|
|||||||
masked_calling: boolean;
|
masked_calling: boolean;
|
||||||
pos_system: boolean;
|
pos_system: boolean;
|
||||||
mobile_app: boolean;
|
mobile_app: boolean;
|
||||||
|
contracts: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Business {
|
export interface Business {
|
||||||
|
|||||||
@@ -9,9 +9,37 @@ from rest_framework import viewsets, status
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
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
|
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 (
|
from .serializers import (
|
||||||
ContractTemplateSerializer, ContractTemplateListSerializer,
|
ContractTemplateSerializer, ContractTemplateListSerializer,
|
||||||
ServiceContractRequirementSerializer, ContractSerializer, ContractListSerializer,
|
ServiceContractRequirementSerializer, ContractSerializer, ContractListSerializer,
|
||||||
@@ -32,10 +60,10 @@ def get_client_ip(request):
|
|||||||
class ContractTemplateViewSet(viewsets.ModelViewSet):
|
class ContractTemplateViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
CRUD for contract templates.
|
CRUD for contract templates.
|
||||||
Permissions: owner/manager only
|
Permissions: owner/manager only, must have contracts feature enabled
|
||||||
"""
|
"""
|
||||||
queryset = ContractTemplate.objects.all()
|
queryset = ContractTemplate.objects.all()
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated, HasContractsPermission]
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
@@ -136,7 +164,7 @@ class ServiceContractRequirementViewSet(viewsets.ModelViewSet):
|
|||||||
"""Manage which contracts are required for which services"""
|
"""Manage which contracts are required for which services"""
|
||||||
queryset = ServiceContractRequirement.objects.all()
|
queryset = ServiceContractRequirement.objects.all()
|
||||||
serializer_class = ServiceContractRequirementSerializer
|
serializer_class = ServiceContractRequirementSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated, HasContractsPermission]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset().select_related("service", "template")
|
qs = super().get_queryset().select_related("service", "template")
|
||||||
@@ -155,7 +183,7 @@ class ContractViewSet(viewsets.ModelViewSet):
|
|||||||
Includes sending, viewing, and PDF download.
|
Includes sending, viewing, and PDF download.
|
||||||
"""
|
"""
|
||||||
queryset = Contract.objects.all()
|
queryset = Contract.objects.all()
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated, HasContractsPermission]
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "list":
|
if self.action == "list":
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -285,6 +285,12 @@ class SubscriptionPlan(models.Model):
|
|||||||
help_text="Monthly fee per proxy number in cents"
|
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 Credit Settings (for new tenants on this tier)
|
||||||
default_auto_reload_enabled = models.BooleanField(
|
default_auto_reload_enabled = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
||||||
# Proxy Number Settings
|
# Proxy Number Settings
|
||||||
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
||||||
|
# Contracts Feature
|
||||||
|
'contracts_enabled',
|
||||||
# Default Credit Settings
|
# Default Credit Settings
|
||||||
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
||||||
'default_auto_reload_amount_cents',
|
'default_auto_reload_amount_cents',
|
||||||
@@ -145,6 +147,8 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
|
|||||||
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
'masked_calling_enabled', 'masked_calling_price_per_minute_cents',
|
||||||
# Proxy Number Settings
|
# Proxy Number Settings
|
||||||
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
'proxy_number_enabled', 'proxy_number_monthly_fee_cents',
|
||||||
|
# Contracts Feature
|
||||||
|
'contracts_enabled',
|
||||||
# Default Credit Settings
|
# Default Credit Settings
|
||||||
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
'default_auto_reload_enabled', 'default_auto_reload_threshold_cents',
|
||||||
'default_auto_reload_amount_cents',
|
'default_auto_reload_amount_cents',
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ def current_business_view(request):
|
|||||||
'masked_calling': tenant.can_use_masked_phone_numbers or plan_permissions.get('masked_calling', False),
|
'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),
|
'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),
|
'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 = {
|
business_data = {
|
||||||
|
|||||||
Reference in New Issue
Block a user