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:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View File

@@ -0,0 +1,293 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import {
useFormValidation,
required,
email,
minLength,
maxLength,
minValue,
maxValue,
pattern,
url,
matches,
phone,
} from '../useFormValidation';
describe('useFormValidation', () => {
describe('hook functionality', () => {
it('initializes with no errors', () => {
const { result } = renderHook(() => useFormValidation({}));
expect(result.current.errors).toEqual({});
expect(result.current.isValid).toBe(true);
});
it('validates form and returns errors', () => {
const schema = {
name: [required('Name is required')],
};
const { result } = renderHook(() => useFormValidation(schema));
act(() => {
result.current.validateForm({ name: '' });
});
expect(result.current.errors.name).toBe('Name is required');
expect(result.current.isValid).toBe(false);
});
it('validates single field', () => {
const schema = {
email: [email('Invalid email')],
};
const { result } = renderHook(() => useFormValidation(schema));
const error = result.current.validateField('email', 'invalid');
expect(error).toBe('Invalid email');
});
it('returns undefined for valid field', () => {
const schema = {
email: [email('Invalid email')],
};
const { result } = renderHook(() => useFormValidation(schema));
const error = result.current.validateField('email', 'test@example.com');
expect(error).toBeUndefined();
});
it('sets error manually', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Custom error');
});
expect(result.current.errors.field).toBe('Custom error');
});
it('clears single error', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Error');
result.current.clearError('field');
});
expect(result.current.errors.field).toBeUndefined();
});
it('clears all errors', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field1', 'Error 1');
result.current.setError('field2', 'Error 2');
result.current.clearAllErrors();
});
expect(result.current.errors).toEqual({});
});
it('getError returns correct error', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Test error');
});
expect(result.current.getError('field')).toBe('Test error');
});
it('hasError returns true when error exists', () => {
const { result } = renderHook(() => useFormValidation({}));
act(() => {
result.current.setError('field', 'Error');
});
expect(result.current.hasError('field')).toBe(true);
});
it('hasError returns false when no error', () => {
const { result } = renderHook(() => useFormValidation({}));
expect(result.current.hasError('field')).toBe(false);
});
});
describe('required validator', () => {
it('returns error for undefined', () => {
const validator = required('Required');
expect(validator(undefined)).toBe('Required');
});
it('returns error for null', () => {
const validator = required('Required');
expect(validator(null)).toBe('Required');
});
it('returns error for empty string', () => {
const validator = required('Required');
expect(validator('')).toBe('Required');
});
it('returns error for empty array', () => {
const validator = required('Required');
expect(validator([])).toBe('Required');
});
it('returns undefined for valid value', () => {
const validator = required('Required');
expect(validator('value')).toBeUndefined();
});
it('uses default message', () => {
const validator = required();
expect(validator('')).toBe('This field is required');
});
});
describe('email validator', () => {
it('returns error for invalid email', () => {
const validator = email('Invalid');
expect(validator('notanemail')).toBe('Invalid');
});
it('returns undefined for valid email', () => {
const validator = email('Invalid');
expect(validator('test@example.com')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = email('Invalid');
expect(validator('')).toBeUndefined();
});
});
describe('minLength validator', () => {
it('returns error when too short', () => {
const validator = minLength(5, 'Too short');
expect(validator('ab')).toBe('Too short');
});
it('returns undefined when long enough', () => {
const validator = minLength(5, 'Too short');
expect(validator('abcde')).toBeUndefined();
});
it('uses default message', () => {
const validator = minLength(5);
expect(validator('ab')).toBe('Must be at least 5 characters');
});
});
describe('maxLength validator', () => {
it('returns error when too long', () => {
const validator = maxLength(3, 'Too long');
expect(validator('abcd')).toBe('Too long');
});
it('returns undefined when short enough', () => {
const validator = maxLength(3, 'Too long');
expect(validator('abc')).toBeUndefined();
});
it('uses default message', () => {
const validator = maxLength(3);
expect(validator('abcd')).toBe('Must be at most 3 characters');
});
});
describe('minValue validator', () => {
it('returns error when below min', () => {
const validator = minValue(10, 'Too small');
expect(validator(5)).toBe('Too small');
});
it('returns undefined when at or above min', () => {
const validator = minValue(10, 'Too small');
expect(validator(10)).toBeUndefined();
});
it('returns undefined for null/undefined', () => {
const validator = minValue(10);
expect(validator(undefined as unknown as number)).toBeUndefined();
});
});
describe('maxValue validator', () => {
it('returns error when above max', () => {
const validator = maxValue(10, 'Too big');
expect(validator(15)).toBe('Too big');
});
it('returns undefined when at or below max', () => {
const validator = maxValue(10, 'Too big');
expect(validator(10)).toBeUndefined();
});
});
describe('pattern validator', () => {
it('returns error when pattern does not match', () => {
const validator = pattern(/^[a-z]+$/, 'Letters only');
expect(validator('abc123')).toBe('Letters only');
});
it('returns undefined when pattern matches', () => {
const validator = pattern(/^[a-z]+$/, 'Letters only');
expect(validator('abc')).toBeUndefined();
});
});
describe('url validator', () => {
it('returns error for invalid URL', () => {
const validator = url('Invalid URL');
expect(validator('not-a-url')).toBe('Invalid URL');
});
it('returns undefined for valid URL', () => {
const validator = url('Invalid URL');
expect(validator('https://example.com')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = url('Invalid URL');
expect(validator('')).toBeUndefined();
});
});
describe('matches validator', () => {
it('returns error when fields do not match', () => {
const validator = matches('password', 'Must match');
expect(validator('abc', { password: 'xyz' })).toBe('Must match');
});
it('returns undefined when fields match', () => {
const validator = matches('password', 'Must match');
expect(validator('abc', { password: 'abc' })).toBeUndefined();
});
it('returns undefined when no form data', () => {
const validator = matches('password');
expect(validator('abc')).toBeUndefined();
});
});
describe('phone validator', () => {
it('returns error for invalid phone', () => {
const validator = phone('Invalid phone');
expect(validator('abc')).toBe('Invalid phone');
});
it('returns undefined for valid phone', () => {
const validator = phone('Invalid phone');
// Use a phone format that matches the regex: /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/
expect(validator('+15551234567')).toBeUndefined();
});
it('returns undefined for empty value', () => {
const validator = phone('Invalid phone');
expect(validator('')).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
// Mock apiClient
vi.mock('../../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
},
}));
import {
useLocations,
useLocation,
useCreateLocation,
useUpdateLocation,
useDeleteLocation,
useSetPrimaryLocation,
useSetLocationActive,
} from '../useLocations';
import apiClient from '../../api/client';
// Create wrapper
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
};
describe('useLocations hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('useLocations', () => {
it('fetches locations and returns data', async () => {
const mockLocations = [
{
id: 1,
name: 'Main Office',
city: 'Denver',
state: 'CO',
is_active: true,
is_primary: true,
display_order: 0,
resource_count: 5,
service_count: 10,
},
{
id: 2,
name: 'Branch Office',
city: 'Boulder',
state: 'CO',
is_active: true,
is_primary: false,
display_order: 1,
},
];
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocations });
const { result } = renderHook(() => useLocations(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/locations/');
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0]).toEqual(expect.objectContaining({
id: 1,
name: 'Main Office',
is_primary: true,
}));
});
it('fetches all locations when includeInactive is true', async () => {
vi.mocked(apiClient.get).mockResolvedValue({ data: [] });
renderHook(() => useLocations({ includeInactive: true }), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/locations/?include_inactive=true');
});
});
});
describe('useLocation', () => {
it('fetches a single location by id', async () => {
const mockLocation = {
id: 1,
name: 'Main Office',
is_active: true,
is_primary: true,
display_order: 0,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: mockLocation });
const { result } = renderHook(() => useLocation(1), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(apiClient.get).toHaveBeenCalledWith('/locations/1/');
expect(result.current.data?.name).toBe('Main Office');
});
it('does not fetch when id is undefined', async () => {
renderHook(() => useLocation(undefined), {
wrapper: createWrapper(),
});
expect(apiClient.get).not.toHaveBeenCalled();
});
});
describe('useCreateLocation', () => {
it('creates location with correct data', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
const { result } = renderHook(() => useCreateLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
name: 'New Location',
city: 'Denver',
state: 'CO',
});
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/', {
name: 'New Location',
city: 'Denver',
state: 'CO',
});
});
});
describe('useUpdateLocation', () => {
it('updates location with mapped fields', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: {} });
const { result } = renderHook(() => useUpdateLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: 1,
updates: {
name: 'Updated Office',
city: 'Boulder',
},
});
});
expect(apiClient.patch).toHaveBeenCalledWith('/locations/1/', {
name: 'Updated Office',
city: 'Boulder',
});
});
});
describe('useDeleteLocation', () => {
it('deletes location by id', async () => {
vi.mocked(apiClient.delete).mockResolvedValue({});
const { result } = renderHook(() => useDeleteLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.delete).toHaveBeenCalledWith('/locations/1/');
});
});
describe('useSetPrimaryLocation', () => {
it('sets location as primary', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_primary: true } });
const { result } = renderHook(() => useSetPrimaryLocation(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync(1);
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_primary/');
});
});
describe('useSetLocationActive', () => {
it('activates location', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: true } });
const { result } = renderHook(() => useSetLocationActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, isActive: true });
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
is_active: true,
});
});
it('deactivates location', async () => {
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, is_active: false } });
const { result } = renderHook(() => useSetLocationActive(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({ id: 1, isActive: false });
});
expect(apiClient.post).toHaveBeenCalledWith('/locations/1/set_active/', {
is_active: false,
});
});
});
});

View File

@@ -67,6 +67,9 @@ describe('useResources hooks', () => {
maxConcurrentEvents: 2,
savedLaneCount: undefined,
userCanEditSchedule: false,
locationId: null,
locationName: null,
isMobile: false,
});
});

View File

@@ -433,6 +433,68 @@ export const useMarkVersionLegacy = () => {
});
};
// Force update response type
export interface ForceUpdateResponse {
message: string;
version: PlanVersion;
affected_count: number;
affected_businesses: string[];
}
// Force update confirmation response (when confirm not provided)
export interface ForceUpdateConfirmRequired {
detail: string;
warning: string;
subscriber_count: number;
requires_confirm: true;
}
/**
* DANGEROUS: Force update a plan version in place, affecting all subscribers.
*
* This bypasses grandfathering and modifies the plan for ALL existing subscribers.
* Only superusers can use this action.
*
* Usage:
* 1. Call without confirm to get subscriber count and warning
* 2. Show warning to user
* 3. Call with confirm: true to execute
*/
export const useForceUpdatePlanVersion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
confirm,
...updates
}: PlanVersionUpdate & { id: number; confirm?: boolean }): Promise<
ForceUpdateResponse | ForceUpdateConfirmRequired
> => {
const { data } = await apiClient.post(
`${BILLING_BASE}/plan-versions/${id}/force_update/`,
{ ...updates, confirm }
);
return data;
},
onSuccess: (data) => {
// Only invalidate if it was a confirmed update (not just checking)
if ('version' in data) {
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
}
},
});
};
/**
* Check if response is a confirmation requirement
*/
export const isForceUpdateConfirmRequired = (
response: ForceUpdateResponse | ForceUpdateConfirmRequired
): response is ForceUpdateConfirmRequired => {
return 'requires_confirm' in response && response.requires_confirm === true;
};
export const usePlanVersionSubscribers = (id: number) => {
return useQuery({
queryKey: ['billingAdmin', 'planVersions', id, 'subscribers'],

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

View File

@@ -38,7 +38,7 @@ export const useCurrentBusiness = () => {
timezone: data.timezone || 'America/New_York',
timezoneDisplayMode: data.timezone_display_mode || 'business',
whitelabelEnabled: data.whitelabel_enabled,
plan: data.tier, // Map tier to plan
plan: data.plan,
status: data.status,
joinedAt: data.created_at ? new Date(data.created_at) : undefined,
resourcesCanReschedule: data.resources_can_reschedule,
@@ -72,6 +72,7 @@ export const useCurrentBusiness = () => {
pos_system: false,
mobile_app: false,
contracts: false,
multi_location: false,
},
};
},

View File

@@ -0,0 +1,153 @@
/**
* Location Management Hooks
*
* Provides hooks for managing business locations in a multi-location setup.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { Location } from '../types';
interface LocationFilters {
includeInactive?: boolean;
}
/**
* Hook to fetch locations with optional inactive filter
*/
export const useLocations = (filters?: LocationFilters) => {
return useQuery<Location[]>({
queryKey: ['locations', filters],
queryFn: async () => {
let url = '/locations/';
if (filters?.includeInactive) {
url += '?include_inactive=true';
}
const { data } = await apiClient.get(url);
return data;
},
});
};
/**
* Hook to get a single location by ID
*/
export const useLocation = (id: number | undefined) => {
return useQuery<Location>({
queryKey: ['locations', id],
queryFn: async () => {
const { data } = await apiClient.get(`/locations/${id}/`);
return data;
},
enabled: id !== undefined,
});
};
/**
* Hook to create a new location
*/
export const useCreateLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (locationData: Partial<Location>) => {
const { data } = await apiClient.post('/locations/', locationData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to update a location
*/
export const useUpdateLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: number; updates: Partial<Location> }) => {
const { data } = await apiClient.patch(`/locations/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to delete a location
*/
export const useDeleteLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/locations/${id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to set a location as primary
*/
export const useSetPrimaryLocation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const { data } = await apiClient.post(`/locations/${id}/set_primary/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to activate or deactivate a location
*/
export const useSetLocationActive = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, isActive }: { id: number; isActive: boolean }) => {
const { data } = await apiClient.post(`/locations/${id}/set_active/`, {
is_active: isActive,
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['locations'] });
},
});
};
/**
* Hook to get only active locations (convenience wrapper)
*/
export const useActiveLocations = () => {
return useLocations();
};
/**
* Hook to get all locations including inactive
*/
export const useAllLocations = () => {
return useLocations({ includeInactive: true });
};
/**
* Hook to get the primary location
*/
export const usePrimaryLocation = () => {
const { data: locations, ...rest } = useLocations();
const primaryLocation = locations?.find(loc => loc.is_primary);
return { data: primaryLocation, locations, ...rest };
};

View File

@@ -93,6 +93,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
pos_system: 'POS System',
mobile_app: 'Mobile App',
contracts: 'Contracts',
multi_location: 'Multiple Locations',
};
/**
@@ -115,4 +116,5 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
pos_system: 'Process in-person payments with Point of Sale',
mobile_app: 'Access SmoothSchedule on mobile devices',
contracts: 'Create and manage contracts with customers',
multi_location: 'Manage multiple business locations with separate resources and services',
};

View File

@@ -11,6 +11,7 @@ import {
updateBusiness,
createBusiness,
deleteBusiness,
changeBusinessPlan,
PlatformBusinessUpdate,
PlatformBusinessCreate,
getTenantInvitations,
@@ -73,6 +74,22 @@ export const useUpdateBusiness = () => {
});
};
/**
* Hook to change a business's subscription plan (platform admin only)
*/
export const useChangeBusinessPlan = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ businessId, planCode }: { businessId: number; planCode: string }) =>
changeBusinessPlan(businessId, planCode),
onSuccess: () => {
// Invalidate and refetch businesses list
queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
},
});
};
/**
* Hook to create a new business (platform admin only)
*/

View File

@@ -0,0 +1,156 @@
/**
* Public Plans Hook
*
* Fetches public plans from the billing API for the marketing pricing page.
* This endpoint doesn't require authentication.
*/
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { API_BASE_URL } from '../api/config';
// =============================================================================
// Types
// =============================================================================
export interface Feature {
id: number;
code: string;
name: string;
description: string;
feature_type: 'boolean' | 'integer';
}
export interface PlanFeature {
id: number;
feature: Feature;
bool_value: boolean | null;
int_value: number | null;
value: boolean | number | null;
}
export interface Plan {
id: number;
code: string;
name: string;
description: string;
display_order: number;
is_active: boolean;
}
export interface PublicPlanVersion {
id: number;
plan: Plan;
version: number;
name: string;
is_public: boolean;
is_legacy: boolean;
price_monthly_cents: number;
price_yearly_cents: number;
transaction_fee_percent: string;
transaction_fee_fixed_cents: number;
trial_days: number;
is_most_popular: boolean;
show_price: boolean;
marketing_features: string[];
is_available: boolean;
features: PlanFeature[];
created_at: string;
}
// =============================================================================
// API Client (no auth required)
// =============================================================================
const publicApiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// =============================================================================
// API Functions
// =============================================================================
/**
* Fetch public plans from the billing catalog.
* No authentication required.
*/
export const fetchPublicPlans = async (): Promise<PublicPlanVersion[]> => {
const response = await publicApiClient.get<PublicPlanVersion[]>('/billing/plans/');
return response.data;
};
// =============================================================================
// Hook
// =============================================================================
/**
* Hook to fetch public plans for the pricing page.
*/
export const usePublicPlans = () => {
return useQuery({
queryKey: ['publicPlans'],
queryFn: fetchPublicPlans,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
});
};
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Format price from cents to dollars with currency symbol.
*/
export const formatPrice = (cents: number): string => {
if (cents === 0) return '$0';
return `$${(cents / 100).toFixed(0)}`;
};
/**
* Get a feature value from a plan version by feature code.
*/
export const getPlanFeatureValue = (
planVersion: PublicPlanVersion,
featureCode: string
): boolean | number | null => {
const planFeature = planVersion.features.find(
(pf) => pf.feature.code === featureCode
);
return planFeature?.value ?? null;
};
/**
* Check if a plan has a boolean feature enabled.
*/
export const hasPlanFeature = (
planVersion: PublicPlanVersion,
featureCode: string
): boolean => {
const value = getPlanFeatureValue(planVersion, featureCode);
return value === true;
};
/**
* Get an integer limit from a plan version.
* Returns 0 if not set (unlimited) or the actual limit.
*/
export const getPlanLimit = (
planVersion: PublicPlanVersion,
featureCode: string
): number => {
const value = getPlanFeatureValue(planVersion, featureCode);
return typeof value === 'number' ? value : 0;
};
/**
* Format a limit value for display.
* 0 means unlimited.
*/
export const formatLimit = (value: number): string => {
if (value === 0) return 'Unlimited';
return value.toLocaleString();
};

View File

@@ -31,6 +31,10 @@ export const useResources = (filters?: ResourceFilters) => {
maxConcurrentEvents: r.max_concurrent_events ?? 1,
savedLaneCount: r.saved_lane_count,
userCanEditSchedule: r.user_can_edit_schedule ?? false,
// Location fields
locationId: r.location ?? null,
locationName: r.location_name ?? null,
isMobile: r.is_mobile ?? false,
}));
},
});
@@ -53,6 +57,10 @@ export const useResource = (id: string) => {
maxConcurrentEvents: data.max_concurrent_events ?? 1,
savedLaneCount: data.saved_lane_count,
userCanEditSchedule: data.user_can_edit_schedule ?? false,
// Location fields
locationId: data.location ?? null,
locationName: data.location_name ?? null,
isMobile: data.is_mobile ?? false,
};
},
enabled: !!id,
@@ -82,6 +90,13 @@ export const useCreateResource = () => {
if (resourceData.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = resourceData.userCanEditSchedule;
}
// Location fields
if (resourceData.locationId !== undefined) {
backendData.location = resourceData.locationId;
}
if (resourceData.isMobile !== undefined) {
backendData.is_mobile = resourceData.isMobile;
}
const { data } = await apiClient.post('/resources/', backendData);
return data;
@@ -115,6 +130,13 @@ export const useUpdateResource = () => {
if (updates.userCanEditSchedule !== undefined) {
backendData.user_can_edit_schedule = updates.userCanEditSchedule;
}
// Location fields
if (updates.locationId !== undefined) {
backendData.location = updates.locationId;
}
if (updates.isMobile !== undefined) {
backendData.is_mobile = updates.isMobile;
}
const { data } = await apiClient.patch(`/resources/${id}/`, backendData);
return data;