- Rename white_label feature to remove_branding across frontend/backend - Update billing catalog, plan features, and permission checks - Add dark mode support to Recharts tooltips with useDarkMode hook - Create embeddable booking widget with EmbedBooking page - Add EmbedWidgetSettings for generating embed code - Fix Appearance settings page permission check - Update test files for new feature naming - Add notes field to User model 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
/**
|
|
* 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: 'remove_branding',
|
|
|
|
// 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 'remove_branding':
|
|
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;
|
|
}
|