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,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();
};