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:
poduck
2025-12-12 01:25:43 -05:00
parent 17786c5ec0
commit 6afa3d7415
57 changed files with 5464 additions and 737 deletions

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

View File

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