Files
smoothschedule/frontend/src/hooks/useBillingPlans.ts
poduck 6a6ad63e7b Consolidate white_label to remove_branding and add embed widget
- 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>
2025-12-16 21:20:17 -05:00

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;
}