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:
184
frontend/src/hooks/__tests__/useEntitlements.test.tsx
Normal file
184
frontend/src/hooks/__tests__/useEntitlements.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
132
frontend/src/hooks/useEntitlements.ts
Normal file
132
frontend/src/hooks/useEntitlements.ts
Normal 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];
|
||||
Reference in New Issue
Block a user