Refactor billing system: add-ons in wizard, remove business_tier, move to top-level app
- Add add-ons step to plan creation wizard (step 4 of 5) - Remove redundant business_tier field from both billing systems: - commerce.billing.PlanVersion (new system) - platform.admin.SubscriptionPlan (legacy system) - Move billing app from commerce.billing to top-level smoothschedule.billing - Create BillingManagement page at /platform/billing with sidebar link - Update plan matching logic to use plan.name instead of business_tier Frontend: - Add BillingManagement.tsx page - Add BillingPlansTab.tsx with unified plan wizard - Add useBillingAdmin.ts hooks - Update TenantInviteModal, BusinessEditModal, BillingSettings to use plan.name - Remove business_tier from usePlatformSettings, payments.ts types Backend: - Move billing app to smoothschedule/billing/ - Add migrations 0006-0009 for plan version settings, feature seeding, business_tier removal - Add platform_admin migration 0013 to remove business_tier - Update seed_subscription_plans command - Update tasks.py to map tier by plan name 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
519
frontend/src/hooks/useBillingAdmin.ts
Normal file
519
frontend/src/hooks/useBillingAdmin.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Billing Admin Hooks
|
||||
*
|
||||
* Hooks for managing plans, features, and addons via the billing admin API.
|
||||
* These use the versioned billing system that supports grandfathering.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export interface Feature {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
feature_type: 'boolean' | 'integer';
|
||||
}
|
||||
|
||||
export interface FeatureCreate {
|
||||
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 PlanFeatureWrite {
|
||||
feature_code: string;
|
||||
bool_value?: boolean | null;
|
||||
int_value?: number | null;
|
||||
}
|
||||
|
||||
export interface PlanVersion {
|
||||
id: number;
|
||||
plan: Plan;
|
||||
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 fees
|
||||
transaction_fee_percent: string; // Decimal comes as string from backend
|
||||
transaction_fee_fixed_cents: number;
|
||||
// Trial
|
||||
trial_days: number;
|
||||
// Communication pricing (costs when feature is enabled)
|
||||
sms_price_per_message_cents: number;
|
||||
masked_calling_price_per_minute_cents: number;
|
||||
proxy_number_monthly_fee_cents: number;
|
||||
// Credit settings
|
||||
default_auto_reload_enabled: boolean;
|
||||
default_auto_reload_threshold_cents: number;
|
||||
default_auto_reload_amount_cents: number;
|
||||
// Display settings
|
||||
is_most_popular: boolean;
|
||||
show_price: boolean;
|
||||
marketing_features: string[];
|
||||
// Stripe
|
||||
stripe_product_id: string;
|
||||
stripe_price_id_monthly: string;
|
||||
stripe_price_id_yearly: string;
|
||||
is_available: boolean;
|
||||
// Features (via PlanFeature M2M - permissions/limits stored here)
|
||||
features: PlanFeature[];
|
||||
subscriber_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlanVersionCreate {
|
||||
plan_code: string;
|
||||
name: string;
|
||||
is_public?: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
price_monthly_cents: number;
|
||||
price_yearly_cents?: number;
|
||||
// Transaction fees
|
||||
transaction_fee_percent?: number;
|
||||
transaction_fee_fixed_cents?: number;
|
||||
// Trial
|
||||
trial_days?: number;
|
||||
// Communication pricing (costs when feature is enabled)
|
||||
sms_price_per_message_cents?: number;
|
||||
masked_calling_price_per_minute_cents?: number;
|
||||
proxy_number_monthly_fee_cents?: number;
|
||||
// Credit settings
|
||||
default_auto_reload_enabled?: boolean;
|
||||
default_auto_reload_threshold_cents?: number;
|
||||
default_auto_reload_amount_cents?: number;
|
||||
// Display settings
|
||||
is_most_popular?: boolean;
|
||||
show_price?: boolean;
|
||||
marketing_features?: string[];
|
||||
// Stripe
|
||||
stripe_product_id?: string;
|
||||
stripe_price_id_monthly?: string;
|
||||
stripe_price_id_yearly?: string;
|
||||
// Features (M2M via PlanFeature - permissions/limits)
|
||||
features?: PlanFeatureWrite[];
|
||||
}
|
||||
|
||||
export interface PlanVersionUpdate {
|
||||
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 fees
|
||||
transaction_fee_percent?: number;
|
||||
transaction_fee_fixed_cents?: number;
|
||||
// Trial
|
||||
trial_days?: number;
|
||||
// Communication pricing (costs when feature is enabled)
|
||||
sms_price_per_message_cents?: number;
|
||||
masked_calling_price_per_minute_cents?: number;
|
||||
proxy_number_monthly_fee_cents?: number;
|
||||
// Credit settings
|
||||
default_auto_reload_enabled?: boolean;
|
||||
default_auto_reload_threshold_cents?: number;
|
||||
default_auto_reload_amount_cents?: number;
|
||||
// Display settings
|
||||
is_most_popular?: boolean;
|
||||
show_price?: boolean;
|
||||
marketing_features?: string[];
|
||||
// Stripe
|
||||
stripe_product_id?: string;
|
||||
stripe_price_id_monthly?: string;
|
||||
stripe_price_id_yearly?: string;
|
||||
// Features (M2M via PlanFeature - permissions/limits)
|
||||
features?: PlanFeatureWrite[];
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PlanWithVersions extends Plan {
|
||||
versions: PlanVersion[];
|
||||
active_version: PlanVersion | null;
|
||||
total_subscribers: number;
|
||||
}
|
||||
|
||||
export interface PlanCreate {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
max_pages?: number;
|
||||
allow_custom_domains?: boolean;
|
||||
max_custom_domains?: number;
|
||||
}
|
||||
|
||||
export interface AddOnProduct {
|
||||
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_active: boolean;
|
||||
}
|
||||
|
||||
export interface AddOnProductCreate {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price_monthly_cents?: number;
|
||||
price_one_time_cents?: number;
|
||||
stripe_product_id?: string;
|
||||
stripe_price_id?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// Grandfathering response when updating a version with subscribers
|
||||
export interface GrandfatheringResponse {
|
||||
message: string;
|
||||
old_version: PlanVersion;
|
||||
new_version: PlanVersion;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Feature Hooks
|
||||
// =============================================================================
|
||||
|
||||
// Note: Billing admin endpoints are at /billing/admin/ not /api/billing/admin/
|
||||
const BILLING_BASE = '/billing/admin';
|
||||
|
||||
export const useFeatures = () => {
|
||||
return useQuery<Feature[]>({
|
||||
queryKey: ['billingAdmin', 'features'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/features/`);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateFeature = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (feature: FeatureCreate) => {
|
||||
const { data } = await apiClient.post(`${BILLING_BASE}/features/`, feature);
|
||||
return data as Feature;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFeature = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...updates }: Partial<FeatureCreate> & { id: number }) => {
|
||||
const { data } = await apiClient.patch(`${BILLING_BASE}/features/${id}/`, updates);
|
||||
return data as Feature;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFeature = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`${BILLING_BASE}/features/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'features'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Plan Hooks
|
||||
// =============================================================================
|
||||
|
||||
export const usePlans = () => {
|
||||
return useQuery<PlanWithVersions[]>({
|
||||
queryKey: ['billingAdmin', 'plans'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/plans/`);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlan = (id: number) => {
|
||||
return useQuery<PlanWithVersions>({
|
||||
queryKey: ['billingAdmin', 'plans', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/plans/${id}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePlan = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (plan: PlanCreate) => {
|
||||
const { data } = await apiClient.post(`${BILLING_BASE}/plans/`, plan);
|
||||
return data as Plan;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePlan = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...updates }: Partial<PlanCreate> & { id: number }) => {
|
||||
const { data } = await apiClient.patch(`${BILLING_BASE}/plans/${id}/`, updates);
|
||||
return data as Plan;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePlan = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`${BILLING_BASE}/plans/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'plans'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Plan Version Hooks
|
||||
// =============================================================================
|
||||
|
||||
export const usePlanVersions = () => {
|
||||
return useQuery<PlanVersion[]>({
|
||||
queryKey: ['billingAdmin', 'planVersions'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/plan-versions/`);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePlanVersion = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (version: PlanVersionCreate) => {
|
||||
const { data } = await apiClient.post(`${BILLING_BASE}/plan-versions/`, version);
|
||||
return data as PlanVersion;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a plan version.
|
||||
*
|
||||
* IMPORTANT: If the version has active subscribers, this will:
|
||||
* 1. Mark the current version as legacy
|
||||
* 2. Create a new version with the updates
|
||||
* 3. Return a GrandfatheringResponse with both versions
|
||||
*
|
||||
* Existing subscribers keep their current version (grandfathering).
|
||||
* New subscribers will get the new version.
|
||||
*/
|
||||
export const useUpdatePlanVersion = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...updates
|
||||
}: PlanVersionUpdate & { id: number }): Promise<PlanVersion | GrandfatheringResponse> => {
|
||||
const { data } = await apiClient.patch(`${BILLING_BASE}/plan-versions/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePlanVersion = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`${BILLING_BASE}/plan-versions/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkVersionLegacy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await apiClient.post(`${BILLING_BASE}/plan-versions/${id}/mark_legacy/`);
|
||||
return data as PlanVersion;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlanVersionSubscribers = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['billingAdmin', 'planVersions', id, 'subscribers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/plan-versions/${id}/subscribers/`);
|
||||
return data as {
|
||||
version: string;
|
||||
subscriber_count: number;
|
||||
subscribers: Array<{
|
||||
business_id: number;
|
||||
business_name: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Add-on Product Hooks
|
||||
// =============================================================================
|
||||
|
||||
export const useAddOnProducts = () => {
|
||||
return useQuery<AddOnProduct[]>({
|
||||
queryKey: ['billingAdmin', 'addons'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`${BILLING_BASE}/addons/`);
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateAddOnProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (addon: AddOnProductCreate) => {
|
||||
const { data } = await apiClient.post(`${BILLING_BASE}/addons/`, addon);
|
||||
return data as AddOnProduct;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAddOnProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...updates }: Partial<AddOnProductCreate> & { id: number }) => {
|
||||
const { data } = await apiClient.patch(`${BILLING_BASE}/addons/${id}/`, updates);
|
||||
return data as AddOnProduct;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAddOnProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`${BILLING_BASE}/addons/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billingAdmin', 'addons'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if a mutation response is a grandfathering response
|
||||
*/
|
||||
export const isGrandfatheringResponse = (
|
||||
response: PlanVersion | GrandfatheringResponse
|
||||
): response is GrandfatheringResponse => {
|
||||
return 'message' in response && 'old_version' in response && 'new_version' in response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format cents to dollars string
|
||||
*/
|
||||
export const formatCentsToDollars = (cents: number): string => {
|
||||
return (cents / 100).toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert dollars to cents
|
||||
*/
|
||||
export const dollarsToCents = (dollars: number): number => {
|
||||
return Math.round(dollars * 100);
|
||||
};
|
||||
@@ -38,7 +38,6 @@ export interface SubscriptionPlan {
|
||||
stripe_price_id: string;
|
||||
price_monthly: string | null;
|
||||
price_yearly: string | null;
|
||||
business_tier: string;
|
||||
features: string[];
|
||||
limits: Record<string, any>;
|
||||
permissions: Record<string, boolean>;
|
||||
@@ -71,7 +70,6 @@ export interface SubscriptionPlanCreate {
|
||||
plan_type?: 'base' | 'addon';
|
||||
price_monthly?: number | null;
|
||||
price_yearly?: number | null;
|
||||
business_tier?: string;
|
||||
features?: string[];
|
||||
limits?: Record<string, any>;
|
||||
permissions?: Record<string, boolean>;
|
||||
|
||||
Reference in New Issue
Block a user