feat: Add subscription/billing/entitlement system

Implements a complete billing system with:

Backend (Django):
- New billing app with models: Feature, Plan, PlanVersion, PlanFeature,
  Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn,
  EntitlementOverride, Invoice, InvoiceLine
- EntitlementService with resolution order: overrides > add-ons > plan
- Invoice generation service with immutable snapshots
- DRF API endpoints for entitlements, subscription, plans, invoices
- Data migrations to seed initial plans and convert existing tenants
- Bridge to legacy Tenant.has_feature() with fallback support
- 75 tests covering models, services, and API endpoints

Frontend (React):
- Billing API client (getEntitlements, getPlans, getInvoices, etc.)
- useEntitlements hook with hasFeature() and getLimit() helpers
- FeatureGate and LimitGate components for conditional rendering
- 29 tests for API, hook, and components

🤖 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-10 03:10:30 -05:00
parent ba2c656243
commit 30ec150d90
32 changed files with 4903 additions and 14 deletions

View File

@@ -0,0 +1,132 @@
/**
* Entitlements Hook
*
* Provides utilities for checking feature availability based on the new billing system.
* This replaces the legacy usePlanFeatures hook for new billing-aware features.
*/
import { useQuery } from '@tanstack/react-query';
import { getEntitlements, Entitlements } from '../api/billing';
export interface UseEntitlementsResult {
/**
* The raw entitlements map
*/
entitlements: Entitlements;
/**
* Whether entitlements are still loading
*/
isLoading: boolean;
/**
* Check if a boolean feature is enabled
*/
hasFeature: (featureCode: string) => boolean;
/**
* Get the limit value for an integer feature
* Returns null if the feature doesn't exist or is not an integer
*/
getLimit: (featureCode: string) => number | null;
/**
* Refetch entitlements
*/
refetch: () => void;
}
/**
* Hook to access entitlements from the billing system.
*
* Usage:
* ```tsx
* const { hasFeature, getLimit, isLoading } = useEntitlements();
*
* if (hasFeature('can_use_sms_reminders')) {
* // Show SMS feature
* }
*
* const maxUsers = getLimit('max_users');
* if (maxUsers !== null && currentUsers >= maxUsers) {
* // Show upgrade prompt
* }
* ```
*/
export const useEntitlements = (): UseEntitlementsResult => {
const { data, isLoading, refetch } = useQuery<Entitlements>({
queryKey: ['entitlements'],
queryFn: getEntitlements,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
const entitlements = data ?? {};
/**
* Check if a boolean feature is enabled.
*/
const hasFeature = (featureCode: string): boolean => {
const value = entitlements[featureCode];
return value === true;
};
/**
* Get the limit value for an integer feature.
* Returns null if the feature doesn't exist or is a boolean.
*/
const getLimit = (featureCode: string): number | null => {
const value = entitlements[featureCode];
// Use strict type check to distinguish integers from booleans
// (typeof true === 'number' is false, but just to be safe)
if (typeof value === 'number' && !Number.isNaN(value)) {
return value;
}
return null;
};
return {
entitlements,
isLoading,
hasFeature,
getLimit,
refetch: () => refetch(),
};
};
/**
* Feature code constants for type safety
*/
export const FEATURE_CODES = {
// Boolean features (permissions)
CAN_ACCEPT_PAYMENTS: 'can_accept_payments',
CAN_USE_CUSTOM_DOMAIN: 'can_use_custom_domain',
CAN_WHITE_LABEL: 'can_white_label',
CAN_API_ACCESS: 'can_api_access',
CAN_USE_SMS_REMINDERS: 'can_use_sms_reminders',
CAN_USE_MASKED_PHONE_NUMBERS: 'can_use_masked_phone_numbers',
CAN_USE_MOBILE_APP: 'can_use_mobile_app',
CAN_USE_CONTRACTS: 'can_use_contracts',
CAN_USE_CALENDAR_SYNC: 'can_use_calendar_sync',
CAN_USE_WEBHOOKS: 'can_use_webhooks',
CAN_USE_PLUGINS: 'can_use_plugins',
CAN_USE_TASKS: 'can_use_tasks',
CAN_CREATE_PLUGINS: 'can_create_plugins',
CAN_EXPORT_DATA: 'can_export_data',
CAN_ADD_VIDEO_CONFERENCING: 'can_add_video_conferencing',
CAN_BOOK_REPEATED_EVENTS: 'can_book_repeated_events',
CAN_REQUIRE_2FA: 'can_require_2fa',
CAN_DOWNLOAD_LOGS: 'can_download_logs',
CAN_DELETE_DATA: 'can_delete_data',
CAN_USE_POS: 'can_use_pos',
CAN_MANAGE_OAUTH_CREDENTIALS: 'can_manage_oauth_credentials',
CAN_CONNECT_TO_API: 'can_connect_to_api',
// Integer features (limits)
MAX_USERS: 'max_users',
MAX_RESOURCES: 'max_resources',
MAX_EVENT_TYPES: 'max_event_types',
MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
} as const;
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];