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

@@ -39,6 +39,7 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -2071,7 +2072,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -2160,8 +2160,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2598,7 +2597,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3300,8 +3298,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4972,7 +4969,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -5415,7 +5411,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -5431,7 +5426,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -5444,8 +5438,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/prismjs": {
"version": "1.30.0",

View File

@@ -35,6 +35,7 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",

View File

@@ -0,0 +1,212 @@
/**
* Tests for Billing API client functions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import apiClient from '../client';
import {
getEntitlements,
getCurrentSubscription,
getPlans,
getAddOns,
getInvoices,
getInvoice,
Entitlements,
Subscription,
PlanVersion,
AddOnProduct,
Invoice,
} from '../billing';
// Mock the API client
vi.mock('../client', () => ({
default: {
get: vi.fn(),
},
}));
describe('Billing API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getEntitlements', () => {
it('fetches entitlements from /api/me/entitlements/', async () => {
const mockEntitlements: Entitlements = {
can_use_sms_reminders: true,
can_use_mobile_app: false,
max_users: 10,
max_resources: 25,
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockEntitlements });
const result = await getEntitlements();
expect(apiClient.get).toHaveBeenCalledWith('/me/entitlements/');
expect(result).toEqual(mockEntitlements);
});
it('returns empty object on error', async () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('Network error'));
const result = await getEntitlements();
expect(result).toEqual({});
});
});
describe('getCurrentSubscription', () => {
it('fetches subscription from /api/me/subscription/', async () => {
const mockSubscription: Subscription = {
id: 1,
status: 'active',
plan_version: {
id: 10,
name: 'Pro Plan v1',
is_legacy: false,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
current_period_start: '2024-01-01T00:00:00Z',
current_period_end: '2024-02-01T00:00:00Z',
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockSubscription });
const result = await getCurrentSubscription();
expect(apiClient.get).toHaveBeenCalledWith('/me/subscription/');
expect(result).toEqual(mockSubscription);
});
it('returns null when no subscription (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getCurrentSubscription();
expect(result).toBeNull();
});
});
describe('getPlans', () => {
it('fetches public plans from /api/billing/plans/', async () => {
const mockPlans: PlanVersion[] = [
{
id: 1,
name: 'Free Plan',
is_legacy: false,
is_public: true,
plan: { code: 'free', name: 'Free' },
price_monthly_cents: 0,
price_yearly_cents: 0,
},
{
id: 2,
name: 'Pro Plan',
is_legacy: false,
is_public: true,
plan: { code: 'pro', name: 'Pro' },
price_monthly_cents: 7900,
price_yearly_cents: 79000,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockPlans });
const result = await getPlans();
expect(apiClient.get).toHaveBeenCalledWith('/billing/plans/');
expect(result).toEqual(mockPlans);
expect(result).toHaveLength(2);
});
});
describe('getAddOns', () => {
it('fetches active add-ons from /api/billing/addons/', async () => {
const mockAddOns: AddOnProduct[] = [
{
id: 1,
code: 'sms_pack',
name: 'SMS Pack',
price_monthly_cents: 500,
is_active: true,
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockAddOns });
const result = await getAddOns();
expect(apiClient.get).toHaveBeenCalledWith('/billing/addons/');
expect(result).toEqual(mockAddOns);
});
});
describe('getInvoices', () => {
it('fetches invoices from /api/billing/invoices/', async () => {
const mockInvoices: Invoice[] = [
{
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
},
];
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoices });
const result = await getInvoices();
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/');
expect(result).toEqual(mockInvoices);
});
});
describe('getInvoice', () => {
it('fetches a single invoice by ID', async () => {
const mockInvoice: Invoice = {
id: 1,
status: 'paid',
period_start: '2024-01-01T00:00:00Z',
period_end: '2024-02-01T00:00:00Z',
subtotal_amount: 7900,
total_amount: 7900,
plan_name_at_billing: 'Pro Plan',
created_at: '2024-01-01T00:00:00Z',
lines: [
{
id: 1,
line_type: 'plan',
description: 'Pro Plan',
quantity: 1,
unit_amount: 7900,
total_amount: 7900,
},
],
};
vi.mocked(apiClient.get).mockResolvedValueOnce({ data: mockInvoice });
const result = await getInvoice(1);
expect(apiClient.get).toHaveBeenCalledWith('/billing/invoices/1/');
expect(result).toEqual(mockInvoice);
});
it('returns null when invoice not found (404)', async () => {
const error = { response: { status: 404 } };
vi.mocked(apiClient.get).mockRejectedValueOnce(error);
const result = await getInvoice(999);
expect(result).toBeNull();
});
});
});

184
frontend/src/api/billing.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* Billing API
*
* API client functions for the billing/subscription system.
*/
import apiClient from './client';
// ============================================================================
// Types
// ============================================================================
/**
* Entitlements - a map of feature codes to their values.
* Boolean features indicate permission (true/false).
* Integer features indicate limits.
*/
export interface Entitlements {
[key: string]: boolean | number | null;
}
/**
* Plan information (nested in PlanVersion)
*/
export interface Plan {
code: string;
name: string;
description?: string;
}
/**
* Plan version with pricing and features
*/
export interface PlanVersion {
id: number;
name: string;
is_legacy: boolean;
is_public?: boolean;
plan: Plan;
price_monthly_cents: number;
price_yearly_cents: number;
features?: PlanFeature[];
}
/**
* Feature attached to a plan version
*/
export interface PlanFeature {
feature_code: string;
feature_name: string;
feature_type: 'boolean' | 'integer';
bool_value?: boolean;
int_value?: number;
}
/**
* Current subscription
*/
export interface Subscription {
id: number;
status: 'active' | 'canceled' | 'past_due' | 'trialing';
plan_version: PlanVersion;
current_period_start: string;
current_period_end: string;
canceled_at?: string;
stripe_subscription_id?: string;
}
/**
* Add-on product
*/
export interface AddOnProduct {
id: number;
code: string;
name: string;
description?: string;
price_monthly_cents: number;
is_active: boolean;
}
/**
* Invoice line item
*/
export interface InvoiceLine {
id: number;
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
description: string;
quantity: number;
unit_amount: number;
total_amount: number;
}
/**
* Invoice
*/
export interface Invoice {
id: number;
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
period_start: string;
period_end: string;
subtotal_amount: number;
total_amount: number;
plan_name_at_billing: string;
plan_code_at_billing?: string;
created_at: string;
paid_at?: string;
lines?: InvoiceLine[];
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Get effective entitlements for the current business.
* Returns a map of feature codes to their values.
*/
export const getEntitlements = async (): Promise<Entitlements> => {
try {
const response = await apiClient.get<Entitlements>('/me/entitlements/');
return response.data;
} catch (error) {
console.error('Failed to fetch entitlements:', error);
return {};
}
};
/**
* Get the current subscription for the business.
* Returns null if no subscription exists.
*/
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
try {
const response = await apiClient.get<Subscription>('/me/subscription/');
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch subscription:', error);
throw error;
}
};
/**
* Get available plans (public, non-legacy plans).
*/
export const getPlans = async (): Promise<PlanVersion[]> => {
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
return response.data;
};
/**
* Get available add-on products.
*/
export const getAddOns = async (): Promise<AddOnProduct[]> => {
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
return response.data;
};
/**
* Get invoices for the current business.
*/
export const getInvoices = async (): Promise<Invoice[]> => {
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
return response.data;
};
/**
* Get a single invoice by ID.
* Returns null if not found.
*/
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
try {
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
return response.data;
} catch (error: any) {
if (error?.response?.status === 404) {
return null;
}
console.error('Failed to fetch invoice:', error);
throw error;
}
};

View File

@@ -0,0 +1,247 @@
/**
* FeatureGate Component
*
* Conditionally renders children based on entitlement checks.
* Used to show/hide features based on the business's subscription plan.
*/
import React from 'react';
import { useEntitlements } from '../hooks/useEntitlements';
// ============================================================================
// FeatureGate - For boolean feature checks
// ============================================================================
interface FeatureGateProps {
/**
* Single feature code to check
*/
feature?: string;
/**
* Multiple feature codes to check
*/
features?: string[];
/**
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
* Default: true (all required)
*/
requireAll?: boolean;
/**
* Content to render when feature(s) are enabled
*/
children: React.ReactNode;
/**
* Content to render when feature(s) are NOT enabled
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on feature entitlements.
*
* @example
* ```tsx
* // Single feature check
* <FeatureGate feature="can_use_sms_reminders">
* <SMSSettings />
* </FeatureGate>
*
* // With fallback
* <FeatureGate
* feature="can_use_sms_reminders"
* fallback={<UpgradePrompt feature="SMS Reminders" />}
* >
* <SMSSettings />
* </FeatureGate>
*
* // Multiple features (all required)
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
* <TaskScheduler />
* </FeatureGate>
*
* // Multiple features (any one)
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
* <NotificationSettings />
* </FeatureGate>
* ```
*/
export const FeatureGate: React.FC<FeatureGateProps> = ({
feature,
features,
requireAll = true,
children,
fallback = null,
loadingFallback = null,
}) => {
const { hasFeature, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
// Determine which features to check
const featuresToCheck = features ?? (feature ? [feature] : []);
if (featuresToCheck.length === 0) {
// No features specified, render children
return <>{children}</>;
}
// Check features
const hasAccess = requireAll
? featuresToCheck.every((f) => hasFeature(f))
: featuresToCheck.some((f) => hasFeature(f));
if (hasAccess) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// LimitGate - For integer limit checks
// ============================================================================
interface LimitGateProps {
/**
* The limit feature code to check (e.g., 'max_users')
*/
limit: string;
/**
* Current usage count
*/
currentUsage: number;
/**
* Content to render when under the limit
*/
children: React.ReactNode;
/**
* Content to render when at or over the limit
*/
fallback?: React.ReactNode;
/**
* Content to render while entitlements are loading
*/
loadingFallback?: React.ReactNode;
}
/**
* Conditionally render content based on usage limits.
*
* @example
* ```tsx
* <LimitGate
* limit="max_users"
* currentUsage={users.length}
* fallback={<UpgradePrompt message="You've reached your user limit" />}
* >
* <AddUserButton />
* </LimitGate>
* ```
*/
export const LimitGate: React.FC<LimitGateProps> = ({
limit,
currentUsage,
children,
fallback = null,
loadingFallback = null,
}) => {
const { getLimit, isLoading } = useEntitlements();
// Show loading state if provided
if (isLoading) {
return <>{loadingFallback}</>;
}
const maxLimit = getLimit(limit);
// If limit is null, treat as unlimited
if (maxLimit === null) {
return <>{children}</>;
}
// Check if under limit
if (currentUsage < maxLimit) {
return <>{children}</>;
}
return <>{fallback}</>;
};
// ============================================================================
// Helper Components
// ============================================================================
interface UpgradePromptProps {
/**
* Feature name to display
*/
feature?: string;
/**
* Custom message
*/
message?: string;
/**
* Upgrade URL (defaults to /settings/billing)
*/
upgradeUrl?: string;
}
/**
* Default upgrade prompt component.
* Can be used as a fallback in FeatureGate/LimitGate.
*/
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
feature,
message,
upgradeUrl = '/settings/billing',
}) => {
const displayMessage =
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
return (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
</div>
<a
href={upgradeUrl}
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
>
View upgrade options &rarr;
</a>
</div>
);
};
export default FeatureGate;

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();
});
});

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