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,270 @@
/**
* Tests for FeatureGate component
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { FeatureGate, LimitGate } from '../FeatureGate';
import * as useEntitlementsModule from '../../hooks/useEntitlements';
// Mock the useEntitlements hook
vi.mock('../../hooks/useEntitlements', () => ({
useEntitlements: vi.fn(),
}));
describe('FeatureGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when feature is enabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: true },
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
});
it('does not render children when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders fallback when feature is disabled', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { can_use_sms_reminders: false },
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
fallback={<div>Upgrade to access SMS</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
});
it('renders nothing while loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate feature="can_use_sms_reminders">
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('renders loading component when provided and loading', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: true,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
feature="can_use_sms_reminders"
loadingFallback={<div>Loading...</div>}
>
<div>SMS Feature Content</div>
</FeatureGate>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=true', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={true}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should not render because mobile_app is disabled
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
});
it('checks multiple features with requireAll=false (any)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {
can_use_sms_reminders: true,
can_use_mobile_app: false,
},
isLoading: false,
hasFeature: (key: string) => key === 'can_use_sms_reminders',
getLimit: () => null,
refetch: vi.fn(),
});
render(
<FeatureGate
features={['can_use_sms_reminders', 'can_use_mobile_app']}
requireAll={false}
>
<div>Multi Feature Content</div>
</FeatureGate>
);
// Should render because at least one (sms) is enabled
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
});
});
describe('LimitGate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children when under limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={5}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
});
it('does not render children when at limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={10}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('does not render children when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={15}>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
});
it('renders fallback when over limit', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: { max_users: 10 },
isLoading: false,
hasFeature: () => false,
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
refetch: vi.fn(),
});
render(
<LimitGate
limit="max_users"
currentUsage={15}
fallback={<div>Upgrade for more users</div>}
>
<div>Under Limit Content</div>
</LimitGate>
);
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
});
it('renders children when limit is null (unlimited)', () => {
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
entitlements: {},
isLoading: false,
hasFeature: () => false,
getLimit: () => null,
refetch: vi.fn(),
});
render(
<LimitGate limit="max_users" currentUsage={1000}>
<div>Unlimited Content</div>
</LimitGate>
);
// When limit is null, treat as unlimited
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
});
});