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,184 @@
/**
* Tests for useEntitlements hook
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useEntitlements } from '../useEntitlements';
import * as billingApi from '../../api/billing';
// Mock the billing API
vi.mock('../../api/billing', () => ({
getEntitlements: vi.fn(),
getCurrentSubscription: vi.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('useEntitlements', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches and returns entitlements', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
// Initially loading
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.entitlements).toEqual(mockEntitlements);
});
it('hasFeature returns true for enabled boolean features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasFeature('can_use_sms_reminders')).toBe(true);
expect(result.current.hasFeature('can_use_mobile_app')).toBe(false);
});
it('hasFeature returns false for non-existent features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasFeature('nonexistent_feature')).toBe(false);
});
it('getLimit returns integer value for limit features', async () => {
const mockEntitlements = {
max_users: 10,
max_resources: 25,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.getLimit('max_users')).toBe(10);
expect(result.current.getLimit('max_resources')).toBe(25);
});
it('getLimit returns null for non-existent limits', async () => {
const mockEntitlements = {
max_users: 10,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.getLimit('nonexistent_limit')).toBeNull();
});
it('getLimit returns null for boolean features', async () => {
const mockEntitlements = {
can_use_sms_reminders: true,
};
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Boolean features should not be returned as limits
expect(result.current.getLimit('can_use_sms_reminders')).toBeNull();
});
it('returns loading state initially', () => {
vi.mocked(billingApi.getEntitlements).mockImplementation(
() => new Promise(() => {}) // Never resolves
);
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.entitlements).toEqual({});
});
it('returns empty entitlements when API returns empty', async () => {
// When getEntitlements encounters an error, it returns {} (see billing.ts)
// So we test that behavior by having the mock return {}
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce({});
const { result } = renderHook(() => useEntitlements(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.entitlements).toEqual({});
expect(result.current.hasFeature('any_feature')).toBe(false);
expect(result.current.getLimit('any_limit')).toBeNull();
});
});

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