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:
247
frontend/src/components/FeatureGate.tsx
Normal file
247
frontend/src/components/FeatureGate.tsx
Normal 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 →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureGate;
|
||||
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user