Add TenantCustomTier system and fix BusinessEditModal feature loading
Backend: - Add TenantCustomTier model for per-tenant feature overrides - Update EntitlementService to check custom tier before plan features - Add custom_tier action on TenantViewSet (GET/PUT/DELETE) - Add Celery task for grace period management (30-day expiry) Frontend: - Add DynamicFeaturesEditor component for dynamic feature management - Fix BusinessEditModal to load features from plan defaults when no custom tier - Update limits (max_users, max_resources, etc.) to use featureValues - Remove outdated canonical feature check from FeaturePicker (removes warning icons) - Add useBillingPlans hook for accessing billing system data - Add custom tier API functions to platform.ts Features now follow consistent rules: - Load from plan defaults when no custom tier exists - Load from custom tier when one exists - Reset to plan defaults when plan changes - Save to custom tier on edit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
372
frontend/src/hooks/useBillingPlans.ts
Normal file
372
frontend/src/hooks/useBillingPlans.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Billing Plans Hooks
|
||||
*
|
||||
* Provides access to the billing system's plans, features, and add-ons.
|
||||
* Used by platform admin for managing tenant subscriptions.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
// Feature from billing system - the SINGLE SOURCE OF TRUTH
|
||||
export interface BillingFeature {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
feature_type: 'boolean' | 'integer';
|
||||
// Dynamic feature management
|
||||
category: 'limits' | 'payments' | 'communication' | 'customization' | 'plugins' | 'advanced' | 'scheduling' | 'enterprise';
|
||||
tenant_field_name: string; // Corresponding field on Tenant model
|
||||
display_order: number;
|
||||
is_overridable: boolean;
|
||||
depends_on: number | null; // ID of parent feature
|
||||
depends_on_code: string | null; // Code of parent feature (for convenience)
|
||||
}
|
||||
|
||||
// Category metadata for display
|
||||
export const FEATURE_CATEGORY_META: Record<BillingFeature['category'], { label: string; order: number }> = {
|
||||
limits: { label: 'Limits', order: 0 },
|
||||
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 },
|
||||
};
|
||||
|
||||
// Plan feature with value
|
||||
export interface BillingPlanFeature {
|
||||
id: number;
|
||||
feature: BillingFeature;
|
||||
bool_value: boolean | null;
|
||||
int_value: number | null;
|
||||
value: boolean | number | null;
|
||||
}
|
||||
|
||||
// Plan (logical grouping)
|
||||
export interface BillingPlan {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
max_pages: number;
|
||||
allow_custom_domains: boolean;
|
||||
max_custom_domains: number;
|
||||
}
|
||||
|
||||
// Plan version (specific offer with pricing and features)
|
||||
export interface BillingPlanVersion {
|
||||
id: number;
|
||||
plan: BillingPlan;
|
||||
version: number;
|
||||
name: string;
|
||||
is_public: boolean;
|
||||
is_legacy: boolean;
|
||||
starts_at: string | null;
|
||||
ends_at: string | null;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents: number;
|
||||
transaction_fee_percent: string;
|
||||
transaction_fee_fixed_cents: number;
|
||||
trial_days: number;
|
||||
sms_price_per_message_cents: number;
|
||||
masked_calling_price_per_minute_cents: number;
|
||||
proxy_number_monthly_fee_cents: number;
|
||||
default_auto_reload_enabled: boolean;
|
||||
default_auto_reload_threshold_cents: number;
|
||||
default_auto_reload_amount_cents: number;
|
||||
is_most_popular: boolean;
|
||||
show_price: boolean;
|
||||
marketing_features: string[];
|
||||
stripe_product_id: string;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
is_available: boolean;
|
||||
features: BillingPlanFeature[];
|
||||
subscriber_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Plan with all versions
|
||||
export interface BillingPlanWithVersions {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
display_order: number;
|
||||
is_active: boolean;
|
||||
max_pages: number;
|
||||
allow_custom_domains: boolean;
|
||||
max_custom_domains: number;
|
||||
versions: BillingPlanVersion[];
|
||||
active_version: BillingPlanVersion | null;
|
||||
total_subscribers: number;
|
||||
}
|
||||
|
||||
// Add-on product
|
||||
export interface BillingAddOn {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price_monthly_cents: number;
|
||||
price_one_time_cents: number;
|
||||
stripe_product_id: string;
|
||||
stripe_price_id: string;
|
||||
is_stackable: boolean;
|
||||
is_active: boolean;
|
||||
features: BillingPlanFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all billing plans with their versions (admin view)
|
||||
*/
|
||||
export const useBillingPlans = () => {
|
||||
return useQuery<BillingPlanWithVersions[]>({
|
||||
queryKey: ['billingPlans'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/admin/plans/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the public plan catalog (available versions only)
|
||||
*/
|
||||
export const useBillingPlanCatalog = () => {
|
||||
return useQuery<BillingPlanVersion[]>({
|
||||
queryKey: ['billingPlanCatalog'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/plans/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get all features
|
||||
*/
|
||||
export const useBillingFeatures = () => {
|
||||
return useQuery<BillingFeature[]>({
|
||||
queryKey: ['billingFeatures'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/admin/features/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes (features rarely change)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get available add-ons
|
||||
*/
|
||||
export const useBillingAddOns = () => {
|
||||
return useQuery<BillingAddOn[]>({
|
||||
queryKey: ['billingAddOns'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/billing/addons/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get a feature value from a plan version's features array
|
||||
*/
|
||||
export function getFeatureValue(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): boolean | number | null {
|
||||
const feature = features.find(f => f.feature.code === featureCode);
|
||||
if (!feature) return null;
|
||||
return feature.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean feature value (defaults to false if not found)
|
||||
*/
|
||||
export function getBooleanFeature(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): boolean {
|
||||
const value = getFeatureValue(features, featureCode);
|
||||
return typeof value === 'boolean' ? value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an integer feature value (defaults to 0 if not found, null means unlimited)
|
||||
*/
|
||||
export function getIntegerFeature(
|
||||
features: BillingPlanFeature[],
|
||||
featureCode: string
|
||||
): number | null {
|
||||
const value = getFeatureValue(features, featureCode);
|
||||
if (value === null || value === undefined) return null; // Unlimited
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a plan version's features to a flat object for form state
|
||||
* Maps feature codes to their values
|
||||
*/
|
||||
export function planFeaturesToFormState(
|
||||
planVersion: BillingPlanVersion | null
|
||||
): Record<string, boolean | number | null> {
|
||||
if (!planVersion) return {};
|
||||
|
||||
const state: Record<string, boolean | number | null> = {};
|
||||
|
||||
for (const pf of planVersion.features) {
|
||||
state[pf.feature.code] = pf.value;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map old tier names to new plan codes
|
||||
*/
|
||||
export const TIER_TO_PLAN_CODE: Record<string, string> = {
|
||||
FREE: 'free',
|
||||
STARTER: 'starter',
|
||||
GROWTH: 'growth',
|
||||
PROFESSIONAL: 'pro', // Old name -> new code
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
/**
|
||||
* Map new plan codes to display names
|
||||
*/
|
||||
export const PLAN_CODE_TO_NAME: Record<string, string> = {
|
||||
free: 'Free',
|
||||
starter: 'Starter',
|
||||
growth: 'Growth',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the active plan version for a given plan code
|
||||
*/
|
||||
export function getActivePlanVersion(
|
||||
plans: BillingPlanWithVersions[],
|
||||
planCode: string
|
||||
): BillingPlanVersion | null {
|
||||
const plan = plans.find(p => p.code === planCode);
|
||||
return plan?.active_version || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature code mapping from old permission names to new feature codes
|
||||
*/
|
||||
export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
|
||||
// Communication
|
||||
can_use_sms_reminders: 'sms_enabled',
|
||||
can_use_masked_phone_numbers: 'masked_calling_enabled',
|
||||
|
||||
// Platform
|
||||
can_api_access: 'api_access',
|
||||
can_use_custom_domain: 'custom_domain',
|
||||
can_white_label: 'white_label',
|
||||
|
||||
// Features
|
||||
can_accept_payments: 'payment_processing',
|
||||
can_use_mobile_app: 'mobile_app_access',
|
||||
advanced_reporting: 'advanced_reporting',
|
||||
priority_support: 'priority_support',
|
||||
dedicated_support: 'dedicated_account_manager',
|
||||
|
||||
// Limits (integer features)
|
||||
max_users: 'max_users',
|
||||
max_resources: 'max_resources',
|
||||
max_locations: 'max_locations',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert plan features to legacy permission format for backward compatibility
|
||||
*/
|
||||
export function planFeaturesToLegacyPermissions(
|
||||
planVersion: BillingPlanVersion | null
|
||||
): Record<string, boolean | number> {
|
||||
if (!planVersion) return {};
|
||||
|
||||
const permissions: Record<string, boolean | number> = {};
|
||||
|
||||
// Map features to legacy permission names
|
||||
for (const pf of planVersion.features) {
|
||||
const code = pf.feature.code;
|
||||
const value = pf.value;
|
||||
|
||||
// Direct feature code
|
||||
permissions[code] = value as boolean | number;
|
||||
|
||||
// Also add with legacy naming for backward compatibility
|
||||
switch (code) {
|
||||
case 'sms_enabled':
|
||||
permissions.can_use_sms_reminders = value as boolean;
|
||||
break;
|
||||
case 'masked_calling_enabled':
|
||||
permissions.can_use_masked_phone_numbers = value as boolean;
|
||||
break;
|
||||
case 'api_access':
|
||||
permissions.can_api_access = value as boolean;
|
||||
permissions.can_connect_to_api = value as boolean;
|
||||
break;
|
||||
case 'custom_domain':
|
||||
permissions.can_use_custom_domain = value as boolean;
|
||||
break;
|
||||
case 'white_label':
|
||||
permissions.can_white_label = value as boolean;
|
||||
break;
|
||||
case 'remove_branding':
|
||||
permissions.can_white_label = permissions.can_white_label || (value as boolean);
|
||||
break;
|
||||
case 'payment_processing':
|
||||
permissions.can_accept_payments = value as boolean;
|
||||
break;
|
||||
case 'mobile_app_access':
|
||||
permissions.can_use_mobile_app = value as boolean;
|
||||
break;
|
||||
case 'advanced_reporting':
|
||||
permissions.advanced_reporting = value as boolean;
|
||||
break;
|
||||
case 'priority_support':
|
||||
permissions.priority_support = value as boolean;
|
||||
break;
|
||||
case 'dedicated_account_manager':
|
||||
permissions.dedicated_support = value as boolean;
|
||||
break;
|
||||
case 'integrations_enabled':
|
||||
permissions.can_use_webhooks = value as boolean;
|
||||
permissions.can_use_calendar_sync = value as boolean;
|
||||
break;
|
||||
case 'team_permissions':
|
||||
permissions.can_require_2fa = value as boolean;
|
||||
break;
|
||||
case 'audit_logs':
|
||||
permissions.can_download_logs = value as boolean;
|
||||
break;
|
||||
case 'custom_branding':
|
||||
permissions.can_customize_booking_page = value as boolean;
|
||||
break;
|
||||
case 'recurring_appointments':
|
||||
permissions.can_book_repeated_events = value as boolean;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
Reference in New Issue
Block a user