From 485f86086b40634f8baec7d3adc857880b3d5e45 Mon Sep 17 00:00:00 2001 From: poduck Date: Wed, 10 Dec 2025 01:27:09 -0500 Subject: [PATCH] feat: Unified FeaturesPermissionsEditor component for plan and business permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create reusable FeaturesPermissionsEditor component with support for both subscription plan editing and individual business permission overrides - Add can_use_contracts field to Tenant model for per-business contracts toggle - Update PlatformSettings.tsx to use unified component for plan permissions - Update BusinessEditModal.tsx to use unified component for business permissions - Update PlatformBusinessUpdate API interface with all permission fields - Add contracts permission mapping to tenant sync task 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/api/platform.ts | 27 + .../platform/FeaturesPermissionsEditor.tsx | 531 ++++++++++++++++++ .../src/pages/platform/PlatformSettings.tsx | 272 +-------- .../platform/components/BusinessEditModal.tsx | 232 +------- .../0023_add_can_use_contracts_field.py | 18 + .../smoothschedule/identity/core/models.py | 4 + .../platform/admin/serializers.py | 2 + .../smoothschedule/platform/admin/tasks.py | 3 + .../scheduling/schedule/api_views.py | 2 +- 9 files changed, 638 insertions(+), 453 deletions(-) create mode 100644 frontend/src/components/platform/FeaturesPermissionsEditor.tsx create mode 100644 smoothschedule/smoothschedule/identity/core/migrations/0023_add_can_use_contracts_field.py diff --git a/frontend/src/api/platform.ts b/frontend/src/api/platform.ts index 4e0505f..43ea283 100644 --- a/frontend/src/api/platform.ts +++ b/frontend/src/api/platform.ts @@ -41,11 +41,38 @@ export interface PlatformBusinessUpdate { subscription_tier?: string; max_users?: number; max_resources?: number; + // Platform permissions can_manage_oauth_credentials?: boolean; can_accept_payments?: boolean; can_use_custom_domain?: boolean; can_white_label?: boolean; can_api_access?: boolean; + // Feature permissions + can_add_video_conferencing?: boolean; + can_connect_to_api?: boolean; + can_book_repeated_events?: boolean; + can_require_2fa?: boolean; + can_download_logs?: boolean; + can_delete_data?: boolean; + can_use_sms_reminders?: boolean; + can_use_masked_phone_numbers?: boolean; + can_use_pos?: boolean; + can_use_mobile_app?: boolean; + can_export_data?: boolean; + can_use_plugins?: boolean; + can_use_tasks?: boolean; + can_create_plugins?: boolean; + can_use_webhooks?: boolean; + can_use_calendar_sync?: boolean; + can_use_contracts?: boolean; + can_process_refunds?: boolean; + can_create_packages?: boolean; + can_use_email_templates?: boolean; + can_customize_booking_page?: boolean; + advanced_reporting?: boolean; + priority_support?: boolean; + dedicated_support?: boolean; + sso_enabled?: boolean; } export interface PlatformBusinessCreate { diff --git a/frontend/src/components/platform/FeaturesPermissionsEditor.tsx b/frontend/src/components/platform/FeaturesPermissionsEditor.tsx new file mode 100644 index 0000000..239e288 --- /dev/null +++ b/frontend/src/components/platform/FeaturesPermissionsEditor.tsx @@ -0,0 +1,531 @@ +/** + * FeaturesPermissionsEditor + * + * A unified component for editing features and permissions. + * Used by both subscription plan editing (PlatformSettings) and + * individual business editing (BusinessEditModal). + * + * Supports two modes: + * - 'plan': For editing subscription plan permissions (uses plan-style keys) + * - 'business': For editing individual business permissions (uses tenant-style keys) + */ + +import React from 'react'; +import { Key } from 'lucide-react'; + +/** + * Permission definition with metadata + */ +interface PermissionDefinition { + key: string; + planKey?: string; // Key used in subscription plan permissions JSON + businessKey?: string; // Key used in tenant/business model fields + label: string; + description?: string; + category: PermissionCategory; + dependsOn?: string; // Key of permission this depends on +} + +type PermissionCategory = + | 'payments' + | 'communication' + | 'customization' + | 'plugins' + | 'advanced' + | 'enterprise' + | 'scheduling'; + +/** + * All available permissions with their mappings + */ +export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [ + // Payments & Revenue + { + key: 'can_accept_payments', + planKey: 'can_accept_payments', + businessKey: 'can_accept_payments', + label: 'Online Payments', + description: 'Accept payments via Stripe Connect', + category: 'payments', + }, + { + key: 'can_process_refunds', + planKey: 'can_process_refunds', + businessKey: 'can_process_refunds', + label: 'Process Refunds', + description: 'Issue refunds for payments', + category: 'payments', + }, + { + key: 'can_create_packages', + planKey: 'can_create_packages', + businessKey: 'can_create_packages', + label: 'Service Packages', + description: 'Create and sell service packages', + category: 'payments', + }, + { + key: 'can_use_pos', + planKey: 'can_use_pos', + businessKey: 'can_use_pos', + label: 'POS System', + description: 'Point of sale for in-person payments', + category: 'payments', + }, + + // Communication + { + key: 'sms_reminders', + planKey: 'sms_reminders', + businessKey: 'can_use_sms_reminders', + label: 'SMS Reminders', + description: 'Send SMS appointment reminders', + category: 'communication', + }, + { + key: 'masked_calling', + planKey: 'can_use_masked_phone_numbers', + businessKey: 'can_use_masked_phone_numbers', + label: 'Masked Calling', + description: 'Use masked phone numbers for privacy', + category: 'communication', + }, + { + key: 'email_templates', + planKey: 'can_use_email_templates', + businessKey: 'can_use_email_templates', + label: 'Email Templates', + description: 'Custom email templates for communications', + category: 'communication', + }, + + // Customization + { + key: 'custom_booking_page', + planKey: 'can_customize_booking_page', + businessKey: 'can_customize_booking_page', + label: 'Custom Booking Page', + description: 'Customize the public booking page', + category: 'customization', + }, + { + key: 'custom_domain', + planKey: 'can_use_custom_domain', + businessKey: 'can_use_custom_domain', + label: 'Custom Domains', + description: 'Use your own domain for booking', + category: 'customization', + }, + { + key: 'white_label', + planKey: 'can_white_label', + businessKey: 'can_white_label', + label: 'White Labelling', + description: 'Remove SmoothSchedule branding', + category: 'customization', + }, + + // Plugins & Automation + { + key: 'plugins', + planKey: 'can_use_plugins', + businessKey: 'can_use_plugins', + label: 'Use Plugins', + description: 'Install and use marketplace plugins', + category: 'plugins', + }, + { + key: 'tasks', + planKey: 'can_use_tasks', + businessKey: 'can_use_tasks', + label: 'Scheduled Tasks', + description: 'Create automated scheduled tasks', + category: 'plugins', + dependsOn: 'plugins', + }, + { + key: 'create_plugins', + planKey: 'can_create_plugins', + businessKey: 'can_create_plugins', + label: 'Create Plugins', + description: 'Build custom plugins', + category: 'plugins', + dependsOn: 'plugins', + }, + + // Advanced Features + { + key: 'api_access', + planKey: 'can_api_access', + businessKey: 'can_api_access', + label: 'API Access', + description: 'Access REST API for integrations', + category: 'advanced', + }, + { + key: 'webhooks', + planKey: 'can_use_webhooks', + businessKey: 'can_use_webhooks', + label: 'Webhooks', + description: 'Receive webhook notifications', + category: 'advanced', + }, + { + key: 'calendar_sync', + planKey: 'calendar_sync', + businessKey: 'can_use_calendar_sync', + label: 'Calendar Sync', + description: 'Sync with Google Calendar, etc.', + category: 'advanced', + }, + { + key: 'export_data', + planKey: 'can_export_data', + businessKey: 'can_export_data', + label: 'Data Export', + description: 'Export data to CSV/Excel', + category: 'advanced', + }, + { + key: 'video_conferencing', + planKey: 'video_conferencing', + businessKey: 'can_add_video_conferencing', + label: 'Video Conferencing', + description: 'Add video links to appointments', + category: 'advanced', + }, + { + key: 'advanced_reporting', + planKey: 'advanced_reporting', + businessKey: 'advanced_reporting', + label: 'Advanced Analytics', + description: 'Detailed reporting and analytics', + category: 'advanced', + }, + { + key: 'contracts', + planKey: 'contracts_enabled', + businessKey: 'can_use_contracts', + label: 'Contracts', + description: 'Create and manage e-signature contracts', + category: 'advanced', + }, + { + key: 'mobile_app', + planKey: 'can_use_mobile_app', + businessKey: 'can_use_mobile_app', + label: 'Mobile App', + description: 'Access via mobile application', + category: 'advanced', + }, + + // Enterprise & Security + { + key: 'manage_oauth', + planKey: 'can_manage_oauth_credentials', + businessKey: 'can_manage_oauth_credentials', + label: 'Manage OAuth', + description: 'Configure custom OAuth credentials', + category: 'enterprise', + }, + { + key: 'require_2fa', + planKey: 'can_require_2fa', + businessKey: 'can_require_2fa', + label: 'Require 2FA', + description: 'Enforce two-factor authentication', + category: 'enterprise', + }, + { + key: 'sso_enabled', + planKey: 'sso_enabled', + businessKey: 'sso_enabled', + label: 'SSO / SAML', + description: 'Single sign-on integration', + category: 'enterprise', + }, + { + key: 'priority_support', + planKey: 'priority_support', + businessKey: 'priority_support', + label: 'Priority Support', + description: 'Faster response times', + category: 'enterprise', + }, + { + key: 'dedicated_support', + planKey: 'dedicated_support', + businessKey: 'dedicated_support', + label: 'Dedicated Support', + description: 'Dedicated account manager', + category: 'enterprise', + }, + + // Scheduling + { + key: 'repeated_events', + planKey: 'can_book_repeated_events', + businessKey: 'can_book_repeated_events', + label: 'Recurring Events', + description: 'Schedule recurring appointments', + category: 'scheduling', + }, +]; + +/** + * Category metadata for display + */ +const CATEGORY_META: Record = { + payments: { label: 'Payments & Revenue', order: 1 }, + communication: { label: 'Communication', order: 2 }, + customization: { label: 'Customization', order: 3 }, + plugins: { label: 'Plugins & Automation', order: 4 }, + advanced: { label: 'Advanced Features', order: 5 }, + scheduling: { label: 'Scheduling', order: 6 }, + enterprise: { label: 'Enterprise & Security', order: 7 }, +}; + +export type EditorMode = 'plan' | 'business'; + +export interface FeaturesPermissionsEditorProps { + /** + * Mode determines which keys are used and which permissions are shown + */ + mode: EditorMode; + + /** + * Current permission values + * For 'plan' mode: the permissions object from subscription plan + * For 'business' mode: flat object with tenant field names + */ + values: Record; + + /** + * Callback when a permission changes + */ + onChange: (key: string, value: boolean) => void; + + /** + * Optional: Limit which categories to show + */ + categories?: PermissionCategory[]; + + /** + * Optional: Limit which permissions to show by key + */ + includeOnly?: string[]; + + /** + * Optional: Hide specific permissions + */ + exclude?: string[]; + + /** + * Number of columns in the grid (default: 3) + */ + columns?: 2 | 3 | 4; + + /** + * Show section header + */ + showHeader?: boolean; + + /** + * Custom header title + */ + headerTitle?: string; + + /** + * Show descriptions under labels + */ + showDescriptions?: boolean; +} + +/** + * Get the appropriate key for a permission based on mode + */ +export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string { + if (mode === 'plan') { + return def.planKey || def.key; + } + return def.businessKey || def.key; +} + +/** + * Convert permissions from one mode to another + */ +export function convertPermissions( + values: Record, + fromMode: EditorMode, + toMode: EditorMode +): Record { + const result: Record = {}; + + for (const def of PERMISSION_DEFINITIONS) { + const fromKey = getPermissionKey(def, fromMode); + const toKey = getPermissionKey(def, toMode); + + if (fromKey in values) { + result[toKey] = values[fromKey]; + } + } + + return result; +} + +/** + * Get permission value from values object + */ +function getPermissionValue( + values: Record, + def: PermissionDefinition, + mode: EditorMode +): boolean { + const key = getPermissionKey(def, mode); + return values[key] ?? false; +} + +/** + * Check if a dependent permission should be disabled + */ +function isDependencyDisabled( + values: Record, + def: PermissionDefinition, + mode: EditorMode +): boolean { + if (!def.dependsOn) return false; + + const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn); + if (!parentDef) return false; + + return !getPermissionValue(values, parentDef, mode); +} + +const FeaturesPermissionsEditor: React.FC = ({ + mode, + values, + onChange, + categories, + includeOnly, + exclude = [], + columns = 3, + showHeader = true, + headerTitle = 'Features & Permissions', + showDescriptions = false, +}) => { + // Filter permissions based on props + const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => { + if (exclude.includes(def.key)) return false; + if (includeOnly && !includeOnly.includes(def.key)) return false; + if (categories && !categories.includes(def.category)) return false; + return true; + }); + + // Group by category + const groupedPermissions = filteredPermissions.reduce((acc, def) => { + if (!acc[def.category]) { + acc[def.category] = []; + } + acc[def.category].push(def); + return acc; + }, {} as Record); + + // Sort categories by order + const sortedCategories = Object.keys(groupedPermissions).sort( + (a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order + ) as PermissionCategory[]; + + const handleChange = (def: PermissionDefinition, checked: boolean) => { + const key = getPermissionKey(def, mode); + onChange(key, checked); + + // If disabling a parent permission, also disable dependents + if (!checked) { + const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key); + for (const dep of dependents) { + const depKey = getPermissionKey(dep, mode); + if (values[depKey]) { + onChange(depKey, false); + } + } + } + }; + + const gridCols = { + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + }; + + return ( +
+ {showHeader && ( + <> +

+ + {headerTitle} +

+

+ Control which features are available. +

+ + )} + + {sortedCategories.map(category => ( +
+

+ {CATEGORY_META[category].label} +

+
+ {groupedPermissions[category].map(def => { + const isChecked = getPermissionValue(values, def, mode); + const isDisabled = isDependencyDisabled(values, def, mode); + const key = getPermissionKey(def, mode); + + return ( + + ); + })} +
+ {/* Show dependency hint for plugins category */} + {category === 'plugins' && !getPermissionValue( + values, + PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!, + mode + ) && ( +

+ Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins +

+ )} +
+ ))} +
+ ); +}; + +export default FeaturesPermissionsEditor; diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index 821fd79..eee3756 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -46,6 +46,7 @@ import { useUpdatePlatformOAuthSettings, } from '../../hooks/usePlatformOAuth'; import { Link } from 'react-router-dom'; +import FeaturesPermissionsEditor, { getPermissionKey, PERMISSION_DEFINITIONS } from '../../components/platform/FeaturesPermissionsEditor'; type TabType = 'general' | 'stripe' | 'tiers' | 'oauth'; @@ -241,6 +242,7 @@ const GeneralSettingsTab: React.FC = () => { }; const StripeSettingsTab: React.FC = () => { + const { t } = useTranslation(); const { data: settings, isLoading, error } = usePlatformSettings(); const updateKeysMutation = useUpdateStripeKeys(); const validateKeysMutation = useValidateStripeKeys(); @@ -1254,25 +1256,6 @@ 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

@@ -1422,238 +1405,25 @@ const PlanModal: React.FC = ({ plan, onSave, onClose, isLoading
- {/* Permissions Configuration */} -
-

- Features & Permissions -

-

- Control which features are available to businesses on this plan. -

- - {/* Payments & Revenue */} -
-

Payments & Revenue

-
- - - -
-
- - {/* Communication */} -
-

Communication

-
- - - -
-
- - {/* Customization */} -
-

Customization

-
- - - -
-
- - {/* Advanced Features */} -
-

Advanced Features

-
- - - - - - - - -
-
- - {/* Support & Enterprise */} -
-

Support & Enterprise

-
- - - -
-
+ {/* Permissions Configuration - Using unified FeaturesPermissionsEditor */} +
+ { + // Handle contracts_enabled specially since it's a top-level plan field + if (key === 'contracts_enabled') { + setFormData((prev) => ({ ...prev, contracts_enabled: value })); + } else { + handlePermissionChange(key, value); + } + }} + headerTitle="Features & Permissions" + />
{/* Display Features (List of strings) */} diff --git a/frontend/src/pages/platform/components/BusinessEditModal.tsx b/frontend/src/pages/platform/components/BusinessEditModal.tsx index 90c01a0..48762ea 100644 --- a/frontend/src/pages/platform/components/BusinessEditModal.tsx +++ b/frontend/src/pages/platform/components/BusinessEditModal.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect } from 'react'; -import { X, Save, Key, RefreshCw } from 'lucide-react'; +import { X, Save, RefreshCw } from 'lucide-react'; import { useUpdateBusiness } from '../../../hooks/usePlatform'; import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings'; import { PlatformBusiness } from '../../../api/platform'; +import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } from '../../../components/platform/FeaturesPermissionsEditor'; // Default tier settings - used when no subscription plans are loaded const TIER_DEFAULTS: Record = ({ business, isOpen, can_create_plugins: false, can_use_webhooks: false, can_use_calendar_sync: false, + can_use_contracts: false, + can_process_refunds: false, + can_create_packages: false, + can_use_email_templates: false, + can_customize_booking_page: false, + advanced_reporting: false, + priority_support: false, + dedicated_support: false, + sso_enabled: false, }); // Get tier defaults from subscription plans or fallback to static defaults @@ -215,6 +225,15 @@ const BusinessEditModal: React.FC = ({ business, isOpen, can_create_plugins: b.can_create_plugins || false, can_use_webhooks: b.can_use_webhooks || false, can_use_calendar_sync: b.can_use_calendar_sync || false, + can_use_contracts: b.can_use_contracts || false, + can_process_refunds: b.can_process_refunds || false, + can_create_packages: b.can_create_packages || false, + can_use_email_templates: b.can_use_email_templates || false, + can_customize_booking_page: b.can_customize_booking_page || false, + advanced_reporting: b.advanced_reporting || false, + priority_support: b.priority_support || false, + dedicated_support: b.dedicated_support || false, + sso_enabled: b.sso_enabled || false, }); } }, [business]); @@ -355,207 +374,18 @@ const BusinessEditModal: React.FC = ({ business, isOpen,
- {/* Features & Permissions */} + {/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
-

- - Features & Permissions -

-

- Control which features are available to this business. -

- - {/* Payments & Revenue */} -
-

Payments & Revenue

-
- -
-
- - {/* Communication */} -
-

Communication

-
- - -
-
- - {/* Customization */} -
-

Customization

-
- - -
-
- - {/* Plugins & Automation */} -
-

Plugins & Automation

-
- - - -
- {!editForm.can_use_plugins && ( -

- Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins -

- )} -
- - {/* Advanced Features */} -
-

Advanced Features

-
- - - - - -
-
- - {/* Enterprise */} -
-

Enterprise

-
- - -
-
+ typeof v === 'boolean') + ) as Record} + onChange={(key, value) => { + setEditForm(prev => ({ ...prev, [key]: value })); + }} + headerTitle="Features & Permissions" + />
diff --git a/smoothschedule/smoothschedule/identity/core/migrations/0023_add_can_use_contracts_field.py b/smoothschedule/smoothschedule/identity/core/migrations/0023_add_can_use_contracts_field.py new file mode 100644 index 0000000..7eb4341 --- /dev/null +++ b/smoothschedule/smoothschedule/identity/core/migrations/0023_add_can_use_contracts_field.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-10 06:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_add_can_use_tasks'), + ] + + operations = [ + migrations.AddField( + model_name='tenant', + name='can_use_contracts', + field=models.BooleanField(default=False, help_text='Whether this business can create and manage e-signature contracts'), + ), + ] diff --git a/smoothschedule/smoothschedule/identity/core/models.py b/smoothschedule/smoothschedule/identity/core/models.py index 7ac1f65..4df20f6 100644 --- a/smoothschedule/smoothschedule/identity/core/models.py +++ b/smoothschedule/smoothschedule/identity/core/models.py @@ -227,6 +227,10 @@ class Tenant(TenantMixin): default=False, help_text="Whether this business can sync Google Calendar and other calendar providers" ) + can_use_contracts = models.BooleanField( + default=False, + help_text="Whether this business can create and manage e-signature contracts" + ) # Stripe Payment Configuration payment_mode = models.CharField( diff --git a/smoothschedule/smoothschedule/platform/admin/serializers.py b/smoothschedule/smoothschedule/platform/admin/serializers.py index b17380d..de042a5 100644 --- a/smoothschedule/smoothschedule/platform/admin/serializers.py +++ b/smoothschedule/smoothschedule/platform/admin/serializers.py @@ -227,6 +227,7 @@ class TenantSerializer(serializers.ModelSerializer): 'can_create_plugins', 'can_use_webhooks', 'can_use_calendar_sync', + 'can_use_contracts', ] read_only_fields = fields @@ -298,6 +299,7 @@ class TenantUpdateSerializer(serializers.ModelSerializer): 'can_create_plugins', 'can_use_webhooks', 'can_use_calendar_sync', + 'can_use_contracts', ] read_only_fields = ['id'] diff --git a/smoothschedule/smoothschedule/platform/admin/tasks.py b/smoothschedule/smoothschedule/platform/admin/tasks.py index 4df64f9..18c805a 100644 --- a/smoothschedule/smoothschedule/platform/admin/tasks.py +++ b/smoothschedule/smoothschedule/platform/admin/tasks.py @@ -274,6 +274,9 @@ def sync_subscription_plan_to_tenants(self, plan_id: int): 'can_use_webhooks': 'can_use_webhooks', 'calendar_sync': 'can_use_calendar_sync', 'can_use_calendar_sync': 'can_use_calendar_sync', + 'contracts': 'can_use_contracts', + 'contracts_enabled': 'can_use_contracts', + 'can_use_contracts': 'can_use_contracts', } # Limit field mappings from plan.limits JSON to Tenant fields diff --git a/smoothschedule/smoothschedule/scheduling/schedule/api_views.py b/smoothschedule/smoothschedule/scheduling/schedule/api_views.py index f5a5ab9..09a271b 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/api_views.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/api_views.py @@ -185,7 +185,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, + 'contracts': tenant.can_use_contracts or (getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False), } business_data = {