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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user