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>
157 lines
4.0 KiB
TypeScript
157 lines
4.0 KiB
TypeScript
/**
|
|
* 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();
|
|
};
|