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

View File

@@ -35,6 +35,7 @@
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@playwright/test": "^1.48.0", "@playwright/test": "^1.48.0",
"@tailwindcss/postcss": "^4.1.17", "@tailwindcss/postcss": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@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];

View File

@@ -50,6 +50,7 @@ SHARED_APPS = [
'djstripe', # Stripe integration 'djstripe', # Stripe integration
# Commerce Domain (shared for platform support) # Commerce Domain (shared for platform support)
'smoothschedule.commerce.billing', # Billing, subscriptions, entitlements
'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access 'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access
# Communication Domain (shared) # Communication Domain (shared)

View File

@@ -97,6 +97,8 @@ urlpatterns += [
path("notifications/", include("smoothschedule.communication.notifications.urls")), path("notifications/", include("smoothschedule.communication.notifications.urls")),
# Messaging API (broadcast messages) # Messaging API (broadcast messages)
path("messages/", include("smoothschedule.communication.messaging.urls")), path("messages/", include("smoothschedule.communication.messaging.urls")),
# Billing API
path("", include("smoothschedule.commerce.billing.api.urls", namespace="billing")),
# Platform API # Platform API
path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")), path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")),
# OAuth Email Integration API # OAuth Email Integration API

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register billing models here

View File

@@ -0,0 +1,214 @@
"""
DRF serializers for billing API endpoints.
"""
from rest_framework import serializers
from smoothschedule.commerce.billing.models import AddOnProduct
from smoothschedule.commerce.billing.models import Feature
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import InvoiceLine
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanFeature
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.models import SubscriptionAddOn
class FeatureSerializer(serializers.ModelSerializer):
"""Serializer for Feature model."""
class Meta:
model = Feature
fields = ["id", "code", "name", "description", "feature_type"]
class PlanSerializer(serializers.ModelSerializer):
"""Serializer for Plan model."""
class Meta:
model = Plan
fields = ["id", "code", "name", "description", "display_order", "is_active"]
class PlanFeatureSerializer(serializers.ModelSerializer):
"""Serializer for PlanFeature model."""
feature = FeatureSerializer(read_only=True)
value = serializers.SerializerMethodField()
class Meta:
model = PlanFeature
fields = ["id", "feature", "bool_value", "int_value", "value"]
def get_value(self, obj):
"""Return the effective value based on feature type."""
return obj.get_value()
class PlanVersionSerializer(serializers.ModelSerializer):
"""Serializer for PlanVersion model."""
plan = PlanSerializer(read_only=True)
features = PlanFeatureSerializer(many=True, read_only=True)
is_available = serializers.BooleanField(read_only=True)
class Meta:
model = PlanVersion
fields = [
"id",
"plan",
"version",
"name",
"is_public",
"is_legacy",
"starts_at",
"ends_at",
"price_monthly_cents",
"price_yearly_cents",
"is_available",
"features",
"created_at",
]
class PlanVersionSummarySerializer(serializers.ModelSerializer):
"""Lightweight serializer for PlanVersion without features."""
plan_code = serializers.CharField(source="plan.code", read_only=True)
plan_name = serializers.CharField(source="plan.name", read_only=True)
class Meta:
model = PlanVersion
fields = [
"id",
"plan_code",
"plan_name",
"version",
"name",
"is_legacy",
"price_monthly_cents",
"price_yearly_cents",
]
class AddOnProductSerializer(serializers.ModelSerializer):
"""Serializer for AddOnProduct model."""
class Meta:
model = AddOnProduct
fields = [
"id",
"code",
"name",
"description",
"price_monthly_cents",
"price_one_time_cents",
"is_active",
]
class SubscriptionAddOnSerializer(serializers.ModelSerializer):
"""Serializer for SubscriptionAddOn model."""
addon = AddOnProductSerializer(read_only=True)
is_active = serializers.BooleanField(read_only=True)
class Meta:
model = SubscriptionAddOn
fields = [
"id",
"addon",
"status",
"activated_at",
"expires_at",
"is_active",
]
class SubscriptionSerializer(serializers.ModelSerializer):
"""Serializer for Subscription model."""
plan_version = PlanVersionSummarySerializer(read_only=True)
addons = SubscriptionAddOnSerializer(many=True, read_only=True)
is_active = serializers.BooleanField(read_only=True)
class Meta:
model = Subscription
fields = [
"id",
"plan_version",
"status",
"is_active",
"started_at",
"current_period_start",
"current_period_end",
"trial_ends_at",
"canceled_at",
"addons",
"created_at",
"updated_at",
]
class InvoiceLineSerializer(serializers.ModelSerializer):
"""Serializer for InvoiceLine model."""
class Meta:
model = InvoiceLine
fields = [
"id",
"line_type",
"description",
"quantity",
"unit_amount",
"subtotal_amount",
"tax_amount",
"total_amount",
"feature_code",
"metadata",
"created_at",
]
class InvoiceSerializer(serializers.ModelSerializer):
"""Serializer for Invoice model."""
lines = InvoiceLineSerializer(many=True, read_only=True)
class Meta:
model = Invoice
fields = [
"id",
"period_start",
"period_end",
"currency",
"subtotal_amount",
"discount_amount",
"tax_amount",
"total_amount",
"status",
"plan_code_at_billing",
"plan_name_at_billing",
"stripe_invoice_id",
"created_at",
"paid_at",
"lines",
]
class InvoiceListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for invoice list."""
class Meta:
model = Invoice
fields = [
"id",
"period_start",
"period_end",
"total_amount",
"status",
"plan_name_at_billing",
"created_at",
"paid_at",
]

View File

@@ -0,0 +1,29 @@
"""
URL routes for billing API endpoints.
"""
from django.urls import path
from smoothschedule.commerce.billing.api.views import AddOnCatalogView
from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
from smoothschedule.commerce.billing.api.views import EntitlementsView
from smoothschedule.commerce.billing.api.views import InvoiceDetailView
from smoothschedule.commerce.billing.api.views import InvoiceListView
from smoothschedule.commerce.billing.api.views import PlanCatalogView
app_name = "billing"
urlpatterns = [
# /api/me/ endpoints (current user/business context)
path("me/entitlements/", EntitlementsView.as_view(), name="me-entitlements"),
path("me/subscription/", CurrentSubscriptionView.as_view(), name="me-subscription"),
# /api/billing/ endpoints
path("billing/plans/", PlanCatalogView.as_view(), name="plan-catalog"),
path("billing/addons/", AddOnCatalogView.as_view(), name="addon-catalog"),
path("billing/invoices/", InvoiceListView.as_view(), name="invoice-list"),
path(
"billing/invoices/<int:invoice_id>/",
InvoiceDetailView.as_view(),
name="invoice-detail",
),
]

View File

@@ -0,0 +1,176 @@
"""
DRF API views for billing endpoints.
"""
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from smoothschedule.commerce.billing.api.serializers import AddOnProductSerializer
from smoothschedule.commerce.billing.api.serializers import InvoiceListSerializer
from smoothschedule.commerce.billing.api.serializers import InvoiceSerializer
from smoothschedule.commerce.billing.api.serializers import PlanVersionSerializer
from smoothschedule.commerce.billing.api.serializers import SubscriptionSerializer
from smoothschedule.commerce.billing.models import AddOnProduct
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.services.entitlements import EntitlementService
class EntitlementsView(APIView):
"""
GET /api/me/entitlements/
Returns the current business's effective entitlements.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response({})
entitlements = EntitlementService.get_effective_entitlements(tenant)
return Response(entitlements)
class CurrentSubscriptionView(APIView):
"""
GET /api/me/subscription/
Returns the current business's subscription with plan version details.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response(
{"detail": "No tenant context"},
status=status.HTTP_400_BAD_REQUEST,
)
subscription = getattr(tenant, "billing_subscription", None)
if not subscription:
return Response(
{"detail": "No subscription found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = SubscriptionSerializer(subscription)
return Response(serializer.data)
class PlanCatalogView(APIView):
"""
GET /api/billing/plans/
Returns public, non-legacy plan versions (the plan catalog).
"""
# This endpoint is public - no authentication required
# Allows visitors to see pricing before signup
def get(self, request):
# Filter for public, non-legacy plans
plan_versions = (
PlanVersion.objects.filter(is_public=True, is_legacy=False)
.select_related("plan")
.prefetch_related("features__feature")
.order_by("plan__display_order", "plan__name", "-version")
)
# Filter by availability window (is_available property)
available_versions = [pv for pv in plan_versions if pv.is_available]
serializer = PlanVersionSerializer(available_versions, many=True)
return Response(serializer.data)
class AddOnCatalogView(APIView):
"""
GET /api/billing/addons/
Returns available add-on products.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
addons = AddOnProduct.objects.filter(is_active=True)
serializer = AddOnProductSerializer(addons, many=True)
return Response(serializer.data)
class InvoiceListView(APIView):
"""
GET /api/billing/invoices/
Returns paginated invoice list for the current business.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response(
{"detail": "No tenant context"},
status=status.HTTP_400_BAD_REQUEST,
)
# Tenant-isolated query
invoices = Invoice.objects.filter(business=tenant).order_by("-created_at")
# Simple pagination
page_size = int(request.query_params.get("page_size", 20))
page = int(request.query_params.get("page", 1))
offset = (page - 1) * page_size
total_count = invoices.count()
invoices_page = invoices[offset : offset + page_size]
serializer = InvoiceListSerializer(invoices_page, many=True)
return Response(
{
"count": total_count,
"page": page,
"page_size": page_size,
"results": serializer.data,
}
)
class InvoiceDetailView(APIView):
"""
GET /api/billing/invoices/{id}/
Returns invoice detail with line items.
"""
permission_classes = [IsAuthenticated]
def get(self, request, invoice_id):
tenant = getattr(request.user, "tenant", None)
if not tenant:
return Response(
{"detail": "No tenant context"},
status=status.HTTP_400_BAD_REQUEST,
)
# Tenant-isolated query - cannot see other tenant's invoices
try:
invoice = Invoice.objects.prefetch_related("lines").get(
business=tenant, id=invoice_id
)
except Invoice.DoesNotExist:
return Response(
{"detail": "Invoice not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = InvoiceSerializer(invoice)
return Response(serializer.data)

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "smoothschedule.commerce.billing"
label = "billing"
verbose_name = "Billing"

View File

@@ -0,0 +1,169 @@
# Generated by Django 5.2.8 on 2025-12-10 07:30
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0023_add_can_use_contracts_field'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AddOnProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('price_monthly_cents', models.PositiveIntegerField(default=0)),
('price_one_time_cents', models.PositiveIntegerField(default=0)),
('stripe_product_id', models.CharField(blank=True, max_length=100)),
('stripe_price_id', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Feature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(db_index=True, max_length=100, unique=True)),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('feature_type', models.CharField(choices=[('boolean', 'Boolean'), ('integer', 'Integer Limit')], max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Plan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(db_index=True, max_length=50, unique=True)),
('name', models.CharField(max_length=100)),
('description', models.TextField(blank=True)),
('display_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
],
options={
'ordering': ['display_order', 'name'],
},
),
migrations.CreateModel(
name='PlanVersion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField(default=1)),
('name', models.CharField(max_length=200)),
('is_public', models.BooleanField(default=True)),
('is_legacy', models.BooleanField(default=False)),
('starts_at', models.DateTimeField(blank=True, null=True)),
('ends_at', models.DateTimeField(blank=True, null=True)),
('price_monthly_cents', models.PositiveIntegerField(default=0)),
('price_yearly_cents', models.PositiveIntegerField(default=0)),
('stripe_product_id', models.CharField(blank=True, max_length=100)),
('stripe_price_id_monthly', models.CharField(blank=True, max_length=100)),
('stripe_price_id_yearly', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='billing.plan')),
],
options={
'ordering': ['plan', '-version'],
'unique_together': {('plan', 'version')},
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('trial', 'Trial'), ('active', 'Active'), ('past_due', 'Past Due'), ('canceled', 'Canceled')], default='active', max_length=20)),
('started_at', models.DateTimeField(auto_now_add=True)),
('current_period_start', models.DateTimeField()),
('current_period_end', models.DateTimeField()),
('trial_ends_at', models.DateTimeField(blank=True, null=True)),
('canceled_at', models.DateTimeField(blank=True, null=True)),
('stripe_subscription_id', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('business', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='billing_subscription', to='core.tenant')),
('plan_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='billing.planversion')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='EntitlementOverride',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source', models.CharField(choices=[('manual', 'Manual Override'), ('promo', 'Promotional'), ('support', 'Support Grant')], max_length=20)),
('bool_value', models.BooleanField(blank=True, null=True)),
('int_value', models.PositiveIntegerField(blank=True, null=True)),
('reason', models.TextField(blank=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entitlement_overrides', to='core.tenant')),
('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='billing.feature')),
],
options={
'ordering': ['-created_at'],
'unique_together': {('business', 'feature')},
},
),
migrations.CreateModel(
name='AddOnFeature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bool_value', models.BooleanField(blank=True, null=True)),
('int_value', models.PositiveIntegerField(blank=True, null=True)),
('addon', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='billing.addonproduct')),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='billing.feature')),
],
options={
'unique_together': {('addon', 'feature')},
},
),
migrations.CreateModel(
name='PlanFeature',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bool_value', models.BooleanField(blank=True, null=True)),
('int_value', models.PositiveIntegerField(blank=True, null=True)),
('feature', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='billing.feature')),
('plan_version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='billing.planversion')),
],
options={
'unique_together': {('plan_version', 'feature')},
},
),
migrations.CreateModel(
name='SubscriptionAddOn',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('trial', 'Trial'), ('active', 'Active'), ('canceled', 'Canceled')], default='active', max_length=20)),
('activated_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('canceled_at', models.DateTimeField(blank=True, null=True)),
('stripe_subscription_item_id', models.CharField(blank=True, max_length=100)),
('addon', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='billing.addonproduct')),
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='billing.subscription')),
],
options={
'ordering': ['-activated_at'],
'unique_together': {('subscription', 'addon')},
},
),
]

View File

@@ -0,0 +1,64 @@
# Generated by Django 5.2.8 on 2025-12-10 07:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0001_initial_billing_models'),
('core', '0023_add_can_use_contracts_field'),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('period_start', models.DateTimeField()),
('period_end', models.DateTimeField()),
('currency', models.CharField(default='USD', max_length=3)),
('subtotal_amount', models.PositiveIntegerField(default=0)),
('discount_amount', models.PositiveIntegerField(default=0)),
('tax_amount', models.PositiveIntegerField(default=0)),
('total_amount', models.PositiveIntegerField(default=0)),
('status', models.CharField(choices=[('draft', 'Draft'), ('open', 'Open'), ('paid', 'Paid'), ('void', 'Void'), ('refunded', 'Refunded')], default='draft', max_length=20)),
('plan_code_at_billing', models.CharField(blank=True, max_length=50)),
('plan_name_at_billing', models.CharField(blank=True, max_length=200)),
('plan_version_id_at_billing', models.PositiveIntegerField(blank=True, null=True)),
('billing_address_snapshot', models.JSONField(blank=True, default=dict)),
('tax_rate_snapshot', models.DecimalField(decimal_places=4, default=0, max_digits=5)),
('stripe_invoice_id', models.CharField(blank=True, max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
('paid_at', models.DateTimeField(blank=True, null=True)),
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='core.tenant')),
('subscription', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='billing.subscription')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='InvoiceLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('line_type', models.CharField(choices=[('plan', 'Plan Subscription'), ('addon', 'Add-On'), ('overage', 'Usage Overage'), ('credit', 'Credit'), ('adjustment', 'Adjustment')], max_length=20)),
('description', models.CharField(max_length=500)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_amount', models.IntegerField(default=0)),
('subtotal_amount', models.IntegerField(default=0)),
('tax_amount', models.IntegerField(default=0)),
('total_amount', models.IntegerField(default=0)),
('feature_code', models.CharField(blank=True, max_length=100)),
('metadata', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('addon', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='billing.addonproduct')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='billing.invoice')),
('plan_version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='billing.planversion')),
],
options={
'ordering': ['id'],
},
),
]

View File

@@ -0,0 +1,296 @@
# Generated by Django 5.2.8 on 2025-12-10 07:45
"""
Data migration to seed initial billing Features, Plans, and PlanVersions.
Maps from legacy tiers (FREE, STARTER, PROFESSIONAL, ENTERPRISE) to new billing models.
"""
from django.db import migrations
def seed_features(apps, schema_editor):
"""Create initial Feature records."""
Feature = apps.get_model('billing', 'Feature')
features = [
# Boolean features (permissions)
{'code': 'can_accept_payments', 'name': 'Accept Payments', 'feature_type': 'boolean',
'description': 'Accept online payments via Stripe Connect'},
{'code': 'can_use_custom_domain', 'name': 'Custom Domain', 'feature_type': 'boolean',
'description': 'Configure a custom domain for your booking page'},
{'code': 'can_white_label', 'name': 'White Labeling', 'feature_type': 'boolean',
'description': 'Remove SmoothSchedule branding'},
{'code': 'can_api_access', 'name': 'API Access', 'feature_type': 'boolean',
'description': 'Access the API for integrations'},
{'code': 'can_use_sms_reminders', 'name': 'SMS Reminders', 'feature_type': 'boolean',
'description': 'Send SMS reminders to customers and staff'},
{'code': 'can_use_masked_phone_numbers', 'name': 'Masked Phone Numbers', 'feature_type': 'boolean',
'description': 'Use masked phone numbers for privacy'},
{'code': 'can_use_mobile_app', 'name': 'Mobile App', 'feature_type': 'boolean',
'description': 'Access the mobile app for field employees'},
{'code': 'can_use_contracts', 'name': 'Contracts', 'feature_type': 'boolean',
'description': 'Create and manage e-signature contracts'},
{'code': 'can_use_calendar_sync', 'name': 'Calendar Sync', 'feature_type': 'boolean',
'description': 'Sync with Google Calendar and other providers'},
{'code': 'can_use_webhooks', 'name': 'Webhooks', 'feature_type': 'boolean',
'description': 'Use webhooks for integrations'},
{'code': 'can_use_plugins', 'name': 'Plugins', 'feature_type': 'boolean',
'description': 'Use plugins from the marketplace'},
{'code': 'can_use_tasks', 'name': 'Scheduled Tasks', 'feature_type': 'boolean',
'description': 'Create scheduled tasks (requires plugins)'},
{'code': 'can_export_data', 'name': 'Data Export', 'feature_type': 'boolean',
'description': 'Export data (appointments, customers, etc.)'},
{'code': 'can_add_video_conferencing', 'name': 'Video Conferencing', 'feature_type': 'boolean',
'description': 'Add video conferencing to events'},
{'code': 'can_book_repeated_events', 'name': 'Recurring Events', 'feature_type': 'boolean',
'description': 'Book recurring/repeated events'},
{'code': 'can_require_2fa', 'name': 'Require 2FA', 'feature_type': 'boolean',
'description': 'Require two-factor authentication for users'},
{'code': 'can_download_logs', 'name': 'Download Logs', 'feature_type': 'boolean',
'description': 'Download system logs'},
{'code': 'can_delete_data', 'name': 'Delete Data', 'feature_type': 'boolean',
'description': 'Permanently delete data'},
{'code': 'can_use_pos', 'name': 'Point of Sale', 'feature_type': 'boolean',
'description': 'Use Point of Sale (POS) system'},
{'code': 'can_manage_oauth_credentials', 'name': 'Manage OAuth', 'feature_type': 'boolean',
'description': 'Manage your own OAuth credentials'},
{'code': 'can_connect_to_api', 'name': 'Connect to API', 'feature_type': 'boolean',
'description': 'Connect to external APIs'},
{'code': 'can_create_plugins', 'name': 'Create Plugins', 'feature_type': 'boolean',
'description': 'Create custom plugins for automation'},
# Integer features (limits)
{'code': 'max_users', 'name': 'Max Users', 'feature_type': 'integer',
'description': 'Maximum number of users'},
{'code': 'max_resources', 'name': 'Max Resources', 'feature_type': 'integer',
'description': 'Maximum number of resources'},
{'code': 'max_event_types', 'name': 'Max Event Types', 'feature_type': 'integer',
'description': 'Maximum number of event types'},
{'code': 'max_calendars_connected', 'name': 'Max Calendars', 'feature_type': 'integer',
'description': 'Maximum number of external calendars connected'},
]
for feature_data in features:
Feature.objects.get_or_create(
code=feature_data['code'],
defaults={
'name': feature_data['name'],
'feature_type': feature_data['feature_type'],
'description': feature_data.get('description', ''),
}
)
def seed_plans_and_versions(apps, schema_editor):
"""Create initial Plan and PlanVersion records."""
Plan = apps.get_model('billing', 'Plan')
PlanVersion = apps.get_model('billing', 'PlanVersion')
Feature = apps.get_model('billing', 'Feature')
PlanFeature = apps.get_model('billing', 'PlanFeature')
# Get features for reference
def get_feature(code):
try:
return Feature.objects.get(code=code)
except Feature.DoesNotExist:
return None
# Plan definitions matching legacy tiers
plans_data = [
{
'code': 'free',
'name': 'Free',
'description': 'Get started with basic scheduling',
'display_order': 1,
'versions': [
{
'version': 1,
'name': 'Free Plan',
'price_monthly_cents': 0,
'price_yearly_cents': 0,
'is_public': True,
'features': {
# Boolean features
'can_book_repeated_events': True,
'can_use_plugins': True,
# Integer limits
'max_users': 2,
'max_resources': 3,
'max_event_types': 3,
}
}
]
},
{
'code': 'starter',
'name': 'Starter',
'description': 'Perfect for small businesses',
'display_order': 2,
'versions': [
{
'version': 1,
'name': 'Starter Plan',
'price_monthly_cents': 2900, # $29/month
'price_yearly_cents': 29000, # $290/year
'is_public': True,
'features': {
# Boolean features
'can_accept_payments': True,
'can_book_repeated_events': True,
'can_use_plugins': True,
'can_use_tasks': True,
'can_export_data': True,
'can_use_calendar_sync': True,
# Integer limits
'max_users': 5,
'max_resources': 10,
'max_event_types': 10,
'max_calendars_connected': 3,
}
}
]
},
{
'code': 'professional',
'name': 'Professional',
'description': 'For growing teams that need more power',
'display_order': 3,
'versions': [
{
'version': 1,
'name': 'Professional Plan',
'price_monthly_cents': 7900, # $79/month
'price_yearly_cents': 79000, # $790/year
'is_public': True,
'features': {
# Boolean features
'can_accept_payments': True,
'can_use_custom_domain': True,
'can_book_repeated_events': True,
'can_use_plugins': True,
'can_use_tasks': True,
'can_export_data': True,
'can_use_calendar_sync': True,
'can_use_sms_reminders': True,
'can_use_mobile_app': True,
'can_use_contracts': True,
'can_add_video_conferencing': True,
'can_api_access': True,
# Integer limits
'max_users': 15,
'max_resources': 30,
'max_event_types': 25,
'max_calendars_connected': 10,
}
}
]
},
{
'code': 'enterprise',
'name': 'Enterprise',
'description': 'Full-featured solution for large organizations',
'display_order': 4,
'versions': [
{
'version': 1,
'name': 'Enterprise Plan',
'price_monthly_cents': 19900, # $199/month
'price_yearly_cents': 199000, # $1990/year
'is_public': True,
'features': {
# Boolean features - all enabled
'can_accept_payments': True,
'can_use_custom_domain': True,
'can_white_label': True,
'can_api_access': True,
'can_use_sms_reminders': True,
'can_use_masked_phone_numbers': True,
'can_use_mobile_app': True,
'can_use_contracts': True,
'can_use_calendar_sync': True,
'can_use_webhooks': True,
'can_use_plugins': True,
'can_use_tasks': True,
'can_create_plugins': True,
'can_export_data': True,
'can_add_video_conferencing': True,
'can_book_repeated_events': True,
'can_require_2fa': True,
'can_download_logs': True,
'can_delete_data': True,
'can_use_pos': True,
'can_manage_oauth_credentials': True,
'can_connect_to_api': True,
# Integer limits - generous
'max_users': 50,
'max_resources': 100,
'max_event_types': 100,
'max_calendars_connected': 50,
}
}
]
},
]
for plan_data in plans_data:
plan, _ = Plan.objects.get_or_create(
code=plan_data['code'],
defaults={
'name': plan_data['name'],
'description': plan_data['description'],
'display_order': plan_data['display_order'],
}
)
for version_data in plan_data['versions']:
pv, created = PlanVersion.objects.get_or_create(
plan=plan,
version=version_data['version'],
defaults={
'name': version_data['name'],
'price_monthly_cents': version_data['price_monthly_cents'],
'price_yearly_cents': version_data['price_yearly_cents'],
'is_public': version_data.get('is_public', True),
'is_legacy': version_data.get('is_legacy', False),
}
)
# Create PlanFeature records if the PlanVersion was just created
if created:
for feature_code, value in version_data['features'].items():
feature = get_feature(feature_code)
if feature:
if feature.feature_type == 'boolean':
PlanFeature.objects.get_or_create(
plan_version=pv,
feature=feature,
defaults={'bool_value': value}
)
elif feature.feature_type == 'integer':
PlanFeature.objects.get_or_create(
plan_version=pv,
feature=feature,
defaults={'int_value': value}
)
def reverse_seed(apps, schema_editor):
"""Reverse migration - delete seeded data."""
Feature = apps.get_model('billing', 'Feature')
Plan = apps.get_model('billing', 'Plan')
# Delete in reverse order of dependencies
Plan.objects.filter(code__in=['free', 'starter', 'professional', 'enterprise']).delete()
Feature.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('billing', '0002_add_invoice_models'),
]
operations = [
migrations.RunPython(seed_features, reverse_seed),
migrations.RunPython(seed_plans_and_versions, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,89 @@
# Generated by Django 5.2.8 on 2025-12-10 07:46
"""
Data migration to create Subscription records for existing Tenants.
Maps existing tenant subscription_tier to new billing Subscription model.
"""
from datetime import timedelta
from django.db import migrations
from django.utils import timezone
# Legacy tier to new plan code mapping
TIER_TO_PLAN_CODE = {
'FREE': 'free',
'STARTER': 'starter',
'PROFESSIONAL': 'professional',
'ENTERPRISE': 'enterprise',
}
def create_subscriptions_for_tenants(apps, schema_editor):
"""Create Subscription records for all existing Tenants."""
Tenant = apps.get_model('core', 'Tenant')
Subscription = apps.get_model('billing', 'Subscription')
Plan = apps.get_model('billing', 'Plan')
PlanVersion = apps.get_model('billing', 'PlanVersion')
now = timezone.now()
for tenant in Tenant.objects.exclude(schema_name='public'):
# Skip if tenant already has a billing_subscription
if Subscription.objects.filter(business=tenant).exists():
continue
# Map legacy tier to new plan code
tier = tenant.subscription_tier or 'FREE'
plan_code = TIER_TO_PLAN_CODE.get(tier, 'free')
# Get the plan version (latest version of the plan)
try:
plan = Plan.objects.get(code=plan_code)
plan_version = PlanVersion.objects.filter(
plan=plan,
is_legacy=False
).order_by('-version').first()
if not plan_version:
plan_version = PlanVersion.objects.filter(plan=plan).order_by('-version').first()
if not plan_version:
# Fallback to free plan
plan = Plan.objects.get(code='free')
plan_version = PlanVersion.objects.filter(plan=plan).order_by('-version').first()
except Plan.DoesNotExist:
# If plans aren't seeded yet, skip this tenant
continue
if not plan_version:
continue
# Create subscription
Subscription.objects.create(
business=tenant,
plan_version=plan_version,
status='active',
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
def reverse_subscriptions(apps, schema_editor):
"""Reverse migration - delete all subscriptions."""
Subscription = apps.get_model('billing', 'Subscription')
Subscription.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('billing', '0003_seed_initial_plans'),
('core', '0001_initial'), # Ensure core models exist
]
operations = [
migrations.RunPython(create_subscriptions_for_tenants, reverse_subscriptions),
]

View File

@@ -0,0 +1,476 @@
"""
Billing models for subscription, entitlement, and invoicing system.
All models are in the public schema (SHARED_APPS) with FK to Tenant.
This enables centralized plan management and simpler queries.
"""
from django.db import models
from django.utils import timezone
class Feature(models.Model):
"""
Reusable capability that can be granted via plans, add-ons, or overrides.
Examples:
- Boolean: 'sms_notifications', 'advanced_reporting', 'api_access'
- Integer: 'max_users', 'max_resources', 'monthly_sms_limit'
"""
FEATURE_TYPE_CHOICES = [
("boolean", "Boolean"),
("integer", "Integer Limit"),
]
code = models.CharField(max_length=100, unique=True, db_index=True)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
feature_type = models.CharField(max_length=20, choices=FEATURE_TYPE_CHOICES)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Plan(models.Model):
"""
Logical plan grouping (Free, Starter, Pro, Enterprise).
Plans are the marketing-level concept. Each Plan can have multiple
PlanVersions for different pricing periods, promotions, etc.
"""
code = models.CharField(max_length=50, unique=True, db_index=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
display_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ["display_order", "name"]
def __str__(self):
return self.name
class PlanVersion(models.Model):
"""
Specific offer of a plan with pricing and feature set.
Each PlanVersion is a concrete billable offer. Examples:
- "Pro Plan v1" (original pricing)
- "Pro Plan - 2024 Holiday Promo" (20% off)
- "Enterprise v2" (new feature set)
Legacy versions (is_legacy=True) are hidden from new signups but
existing subscribers can continue using them (grandfathering).
"""
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name="versions")
version = models.PositiveIntegerField(default=1)
name = models.CharField(max_length=200)
# Visibility
is_public = models.BooleanField(default=True)
is_legacy = models.BooleanField(default=False)
# Availability window (null = no constraint)
starts_at = models.DateTimeField(null=True, blank=True)
ends_at = models.DateTimeField(null=True, blank=True)
# Pricing (in cents / minor currency units)
price_monthly_cents = models.PositiveIntegerField(default=0)
price_yearly_cents = models.PositiveIntegerField(default=0)
# Stripe integration
stripe_product_id = models.CharField(max_length=100, blank=True)
stripe_price_id_monthly = models.CharField(max_length=100, blank=True)
stripe_price_id_yearly = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["plan", "version"]
ordering = ["plan", "-version"]
def __str__(self):
return self.name
@property
def is_available(self) -> bool:
"""Check if this version is available for new signups."""
if not self.is_public or self.is_legacy:
return False
now = timezone.now()
if self.starts_at and now < self.starts_at:
return False
if self.ends_at and now > self.ends_at:
return False
return True
class PlanFeature(models.Model):
"""
Maps a PlanVersion to Features with specific values.
For boolean features, use bool_value.
For integer features, use int_value.
"""
plan_version = models.ForeignKey(
PlanVersion, on_delete=models.CASCADE, related_name="features"
)
feature = models.ForeignKey(Feature, on_delete=models.CASCADE)
bool_value = models.BooleanField(null=True, blank=True)
int_value = models.PositiveIntegerField(null=True, blank=True)
class Meta:
unique_together = ["plan_version", "feature"]
def __str__(self):
return f"{self.plan_version.name} - {self.feature.name}"
def get_value(self):
"""Return the appropriate value based on feature type."""
if self.feature.feature_type == "boolean":
return self.bool_value
return self.int_value
class Subscription(models.Model):
"""
A business's active subscription to a PlanVersion.
This is a public schema model with FK to Tenant, enabling
centralized billing management.
"""
STATUS_CHOICES = [
("trial", "Trial"),
("active", "Active"),
("past_due", "Past Due"),
("canceled", "Canceled"),
]
# FK to Tenant (public schema)
business = models.OneToOneField(
"core.Tenant",
on_delete=models.CASCADE,
related_name="billing_subscription",
)
plan_version = models.ForeignKey(PlanVersion, on_delete=models.PROTECT)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="active")
started_at = models.DateTimeField(auto_now_add=True)
current_period_start = models.DateTimeField()
current_period_end = models.DateTimeField()
trial_ends_at = models.DateTimeField(null=True, blank=True)
canceled_at = models.DateTimeField(null=True, blank=True)
# Stripe integration
stripe_subscription_id = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.business.name} - {self.plan_version.name}"
@property
def is_active(self) -> bool:
"""Check if subscription grants access to features."""
return self.status in ("active", "trial")
class AddOnProduct(models.Model):
"""
Purchasable add-on product that grants additional features.
Add-ons can be:
- Monthly recurring (price_monthly_cents > 0)
- One-time purchase (price_one_time_cents > 0)
"""
code = models.CharField(max_length=50, unique=True, db_index=True)
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
# Pricing (in cents)
price_monthly_cents = models.PositiveIntegerField(default=0)
price_one_time_cents = models.PositiveIntegerField(default=0)
# Stripe integration
stripe_product_id = models.CharField(max_length=100, blank=True)
stripe_price_id = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class AddOnFeature(models.Model):
"""
Maps an AddOnProduct to Features.
Similar to PlanFeature, but for add-on products.
"""
addon = models.ForeignKey(
AddOnProduct, on_delete=models.CASCADE, related_name="features"
)
feature = models.ForeignKey(Feature, on_delete=models.CASCADE)
bool_value = models.BooleanField(null=True, blank=True)
int_value = models.PositiveIntegerField(null=True, blank=True)
class Meta:
unique_together = ["addon", "feature"]
def __str__(self):
return f"{self.addon.name} - {self.feature.name}"
def get_value(self):
"""Return the appropriate value based on feature type."""
if self.feature.feature_type == "boolean":
return self.bool_value
return self.int_value
class SubscriptionAddOn(models.Model):
"""
Links an add-on to a subscription.
Tracks when add-ons were activated, when they expire,
and their Stripe subscription item ID.
"""
STATUS_CHOICES = [
("trial", "Trial"),
("active", "Active"),
("canceled", "Canceled"),
]
subscription = models.ForeignKey(
Subscription, on_delete=models.CASCADE, related_name="addons"
)
addon = models.ForeignKey(AddOnProduct, on_delete=models.PROTECT)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="active")
activated_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
canceled_at = models.DateTimeField(null=True, blank=True)
# Stripe integration
stripe_subscription_item_id = models.CharField(max_length=100, blank=True)
class Meta:
unique_together = ["subscription", "addon"]
ordering = ["-activated_at"]
def __str__(self):
return f"{self.subscription.business.name} - {self.addon.name}"
@property
def is_active(self) -> bool:
"""Check if this add-on is currently active."""
if self.status != "active":
return False
if self.expires_at and self.expires_at < timezone.now():
return False
return True
class EntitlementOverride(models.Model):
"""
Per-business override for a specific feature.
Overrides take highest precedence in entitlement resolution.
Use cases:
- Manual: Support grants temporary access
- Promo: Marketing promotion
- Support: Technical support grant for debugging
"""
SOURCE_CHOICES = [
("manual", "Manual Override"),
("promo", "Promotional"),
("support", "Support Grant"),
]
# FK to Tenant (public schema)
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="entitlement_overrides",
)
feature = models.ForeignKey(Feature, on_delete=models.CASCADE)
source = models.CharField(max_length=20, choices=SOURCE_CHOICES)
bool_value = models.BooleanField(null=True, blank=True)
int_value = models.PositiveIntegerField(null=True, blank=True)
reason = models.TextField(blank=True)
granted_by = models.ForeignKey(
"users.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
expires_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["business", "feature"]
ordering = ["-created_at"]
def __str__(self):
return f"{self.business.name} - {self.feature.name} override"
@property
def is_active(self) -> bool:
"""Check if this override is currently active."""
if self.expires_at and self.expires_at < timezone.now():
return False
return True
def get_value(self):
"""Return the appropriate value based on feature type."""
if self.feature.feature_type == "boolean":
return self.bool_value
return self.int_value
class Invoice(models.Model):
"""
Immutable billing snapshot.
Once status='paid', this record and its InvoiceLines are NEVER modified.
This ensures historical accuracy for accounting and auditing.
"""
STATUS_CHOICES = [
("draft", "Draft"),
("open", "Open"),
("paid", "Paid"),
("void", "Void"),
("refunded", "Refunded"),
]
# FK to Tenant (public schema)
business = models.ForeignKey(
"core.Tenant",
on_delete=models.CASCADE,
related_name="invoices",
)
subscription = models.ForeignKey(
Subscription,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="invoices",
)
# Billing period
period_start = models.DateTimeField()
period_end = models.DateTimeField()
# Currency
currency = models.CharField(max_length=3, default="USD")
# Amounts (in cents - IMMUTABLE after paid)
subtotal_amount = models.PositiveIntegerField(default=0)
discount_amount = models.PositiveIntegerField(default=0)
tax_amount = models.PositiveIntegerField(default=0)
total_amount = models.PositiveIntegerField(default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="draft")
# Snapshots (IMMUTABLE - captured at invoice creation)
plan_code_at_billing = models.CharField(max_length=50, blank=True)
plan_name_at_billing = models.CharField(max_length=200, blank=True)
plan_version_id_at_billing = models.PositiveIntegerField(null=True, blank=True)
billing_address_snapshot = models.JSONField(default=dict, blank=True)
tax_rate_snapshot = models.DecimalField(
max_digits=5, decimal_places=4, default=0
)
# External
stripe_invoice_id = models.CharField(max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
paid_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Invoice {self.id} - {self.business.name} ({self.status})"
class InvoiceLine(models.Model):
"""
Line item on an invoice.
Each line represents a charge (plan, add-on, overage) or credit.
Amounts are in cents.
"""
LINE_TYPE_CHOICES = [
("plan", "Plan Subscription"),
("addon", "Add-On"),
("overage", "Usage Overage"),
("credit", "Credit"),
("adjustment", "Adjustment"),
]
invoice = models.ForeignKey(
Invoice, on_delete=models.CASCADE, related_name="lines"
)
line_type = models.CharField(max_length=20, choices=LINE_TYPE_CHOICES)
description = models.CharField(max_length=500)
quantity = models.PositiveIntegerField(default=1)
unit_amount = models.IntegerField(default=0) # Can be negative for credits
subtotal_amount = models.IntegerField(default=0)
tax_amount = models.IntegerField(default=0)
total_amount = models.IntegerField(default=0)
# Context (for audit trail)
feature_code = models.CharField(max_length=100, blank=True)
plan_version = models.ForeignKey(
PlanVersion,
on_delete=models.SET_NULL,
null=True,
blank=True,
)
addon = models.ForeignKey(
AddOnProduct,
on_delete=models.SET_NULL,
null=True,
blank=True,
)
metadata = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["id"]
def __str__(self):
return f"{self.line_type}: {self.description} ({self.total_amount} cents)"

View File

@@ -0,0 +1,148 @@
"""
EntitlementService - Central service for resolving effective entitlements.
Resolution order (highest to lowest precedence):
1. Active, non-expired EntitlementOverrides
2. Active, non-expired SubscriptionAddOn features
3. Base PlanVersion features from Subscription
For integer features (limits), when multiple sources grant the same feature,
the highest value wins.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from smoothschedule.identity.core.models import Tenant
class EntitlementService:
"""Central service for resolving effective entitlements for a business."""
@staticmethod
def get_effective_entitlements(business: Tenant) -> dict[str, bool | int | None]:
"""
Returns all effective entitlements for a business.
Resolution order (highest to lowest precedence):
1. Active, non-expired EntitlementOverrides
2. Active, non-expired SubscriptionAddOn features
3. Base PlanVersion features from Subscription
For integer features, when multiple sources grant the same feature,
the highest value wins (except overrides which always take precedence).
Args:
business: The Tenant to get entitlements for
Returns:
dict mapping feature codes to their values (bool or int)
"""
# Check if business has an active subscription
subscription = getattr(business, "billing_subscription", None)
if subscription is None:
return {}
if not subscription.is_active:
return {}
result: dict[str, bool | int | None] = {}
# Layer 1: Base plan features (lowest precedence)
plan_features = subscription.plan_version.features.select_related(
"feature"
).all()
for pf in plan_features:
result[pf.feature.code] = pf.get_value()
# Layer 2: Add-on features (stacks on top of plan)
# For boolean: any True wins
# For integer: highest value wins
active_addons = subscription.addons.filter(status="active").all()
for subscription_addon in active_addons:
if not subscription_addon.is_active:
continue
for af in subscription_addon.addon.features.all():
feature_code = af.feature.code
addon_value = af.get_value()
if feature_code not in result:
result[feature_code] = addon_value
elif af.feature.feature_type == "integer":
# For integer features, take the max value
current = result.get(feature_code)
if current is None or (
addon_value is not None and addon_value > current
):
result[feature_code] = addon_value
elif af.feature.feature_type == "boolean":
# For boolean features, True wins over False
if addon_value is True:
result[feature_code] = True
# Layer 3: Overrides (highest precedence - always wins)
overrides = business.entitlement_overrides.select_related("feature").all()
for override in overrides:
if not override.is_active:
continue
result[override.feature.code] = override.get_value()
return result
@staticmethod
def has_feature(business: Tenant, feature_code: str) -> bool:
"""
Check if business has a boolean feature enabled.
For boolean features, returns the value directly.
For integer features, returns True if value > 0.
Args:
business: The Tenant to check
feature_code: The feature code to check
Returns:
True if feature is enabled/granted, False otherwise
"""
entitlements = EntitlementService.get_effective_entitlements(business)
value = entitlements.get(feature_code)
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, int):
return value > 0
return False
@staticmethod
def get_limit(business: Tenant, feature_code: str) -> int | None:
"""
Get the limit value for an integer feature.
Args:
business: The Tenant to check
feature_code: The feature code to get limit for
Returns:
The integer limit value, or None if not found or not an integer feature
"""
entitlements = EntitlementService.get_effective_entitlements(business)
value = entitlements.get(feature_code)
if value is None:
return None
# Use type() instead of isinstance() because bool is a subclass of int
# We want to return None for boolean features
if type(value) is int:
return value
# Boolean features don't have limits
return None

View File

@@ -0,0 +1,108 @@
"""
Invoice generation service.
Creates immutable Invoice records with snapshot data from the subscription.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import InvoiceLine
def generate_invoice_for_subscription(
subscription: Subscription,
period_start: datetime,
period_end: datetime,
) -> Invoice:
"""
Creates an Invoice + InvoiceLines with SNAPSHOT values.
1. Creates Invoice with plan snapshots
2. Creates line item for base plan
3. Creates line items for active add-ons
4. Calculates subtotal, tax, total
5. Returns created Invoice
Note: Prices are snapshotted - future plan price changes
do NOT affect existing invoices.
Args:
subscription: The Subscription to invoice
period_start: Start of the billing period
period_end: End of the billing period
Returns:
The created Invoice instance
"""
plan_version = subscription.plan_version
# Create invoice with snapshot data
invoice = Invoice(
business=subscription.business,
subscription=subscription,
period_start=period_start,
period_end=period_end,
# Snapshot values - these are IMMUTABLE
plan_code_at_billing=plan_version.plan.code,
plan_name_at_billing=plan_version.name,
plan_version_id_at_billing=plan_version.id,
)
invoice.save()
# Track totals
subtotal = 0
# Create line item for base plan
plan_amount = plan_version.price_monthly_cents
plan_line = InvoiceLine(
invoice=invoice,
line_type="plan",
description=f"{plan_version.name} - Monthly Subscription",
quantity=1,
unit_amount=plan_amount,
subtotal_amount=plan_amount,
tax_amount=0, # Tax calculation is stubbed
total_amount=plan_amount,
plan_version=plan_version,
)
plan_line.save()
subtotal += plan_amount
# Create line items for active add-ons
active_addons = subscription.addons.filter(status="active").all()
for subscription_addon in active_addons:
if not subscription_addon.is_active:
continue
addon = subscription_addon.addon
addon_amount = addon.price_monthly_cents
addon_line = InvoiceLine(
invoice=invoice,
line_type="addon",
description=f"{addon.name} - Add-On",
quantity=1,
unit_amount=addon_amount,
subtotal_amount=addon_amount,
tax_amount=0, # Tax calculation is stubbed
total_amount=addon_amount,
addon=addon,
)
addon_line.save()
subtotal += addon_amount
# Update invoice totals
invoice.subtotal_amount = subtotal
invoice.discount_amount = 0 # Discount calculation is stubbed
invoice.tax_amount = 0 # Tax calculation is stubbed
invoice.total_amount = subtotal # subtotal - discount + tax
invoice.save()
return invoice

View File

@@ -0,0 +1,383 @@
"""
API tests for billing endpoints.
Tests verify:
- /api/me/entitlements/ returns effective entitlements
- /api/me/subscription/ returns subscription with is_legacy flag
- /api/billing/plans/ filters by is_public and date window
- /api/billing/invoices/ is tenant-isolated
- /api/billing/invoices/{id}/ returns 404 for other tenant's invoice
"""
from datetime import timedelta
from unittest.mock import Mock
from unittest.mock import patch
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APIClient
@pytest.fixture
def clean_tenant_subscription(shared_tenant):
"""Delete any existing subscription for shared_tenant before test."""
from smoothschedule.commerce.billing.models import Subscription
Subscription.objects.filter(business=shared_tenant).delete()
yield shared_tenant
@pytest.fixture
def clean_second_tenant_subscription(second_shared_tenant):
"""Delete any existing subscription for second_shared_tenant before test."""
from smoothschedule.commerce.billing.models import Subscription
Subscription.objects.filter(business=second_shared_tenant).delete()
yield second_shared_tenant
# =============================================================================
# /api/me/entitlements/ Tests
# =============================================================================
class TestEntitlementsEndpoint:
"""Tests for the /api/me/entitlements/ endpoint."""
def test_returns_entitlements_from_service(self):
"""Endpoint should return dict from EntitlementService."""
from smoothschedule.commerce.billing.api.views import EntitlementsView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
with patch(
"smoothschedule.commerce.billing.api.views.EntitlementService"
) as MockService:
MockService.get_effective_entitlements.return_value = {
"sms": True,
"max_users": 10,
"advanced_reporting": False,
}
view = EntitlementsView()
view.request = mock_request
response = view.get(mock_request)
assert response.status_code == 200
assert response.data["sms"] is True
assert response.data["max_users"] == 10
assert response.data["advanced_reporting"] is False
def test_returns_empty_dict_when_no_subscription(self):
"""Endpoint should return empty dict when no subscription."""
from smoothschedule.commerce.billing.api.views import EntitlementsView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
with patch(
"smoothschedule.commerce.billing.api.views.EntitlementService"
) as MockService:
MockService.get_effective_entitlements.return_value = {}
view = EntitlementsView()
view.request = mock_request
response = view.get(mock_request)
assert response.status_code == 200
assert response.data == {}
# =============================================================================
# /api/me/subscription/ Tests
# =============================================================================
class TestSubscriptionEndpoint:
"""Tests for the /api/me/subscription/ endpoint."""
def test_returns_subscription_with_is_legacy_flag(self):
"""Subscription response should include is_legacy flag."""
from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
mock_subscription = Mock()
mock_subscription.id = 1
mock_subscription.status = "active"
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.id = 10
mock_subscription.plan_version.name = "Pro Plan v1"
mock_subscription.plan_version.is_legacy = True
mock_subscription.plan_version.plan = Mock()
mock_subscription.plan_version.plan.code = "pro"
mock_subscription.current_period_start = timezone.now()
mock_subscription.current_period_end = timezone.now() + timedelta(days=30)
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
mock_request.user.tenant.billing_subscription = mock_subscription
view = CurrentSubscriptionView()
view.request = mock_request
with patch(
"smoothschedule.commerce.billing.api.views.SubscriptionSerializer"
) as MockSerializer:
mock_serializer = Mock()
mock_serializer.data = {
"id": 1,
"status": "active",
"plan_version": {
"id": 10,
"name": "Pro Plan v1",
"is_legacy": True,
"plan": {"code": "pro"},
},
}
MockSerializer.return_value = mock_serializer
response = view.get(mock_request)
assert response.status_code == 200
assert response.data["plan_version"]["is_legacy"] is True
def test_returns_404_when_no_subscription(self):
"""Should return 404 when tenant has no subscription."""
from smoothschedule.commerce.billing.api.views import CurrentSubscriptionView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
mock_request.user.tenant.billing_subscription = None
view = CurrentSubscriptionView()
view.request = mock_request
response = view.get(mock_request)
assert response.status_code == 404
# =============================================================================
# /api/billing/plans/ Tests
# =============================================================================
@pytest.mark.django_db
class TestPlansEndpoint:
"""Tests for the /api/billing/plans/ endpoint."""
def test_filters_by_is_public_true(self):
"""Should only return public plan versions."""
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.api.views import PlanCatalogView
# Create public and non-public plans
plan = Plan.objects.create(code="test_public", name="Test Public Plan")
public_pv = PlanVersion.objects.create(
plan=plan, version=1, name="Public v1", is_public=True, is_legacy=False
)
private_pv = PlanVersion.objects.create(
plan=plan, version=2, name="Private v2", is_public=False, is_legacy=False
)
mock_request = Mock()
view = PlanCatalogView()
view.request = mock_request
response = view.get(mock_request)
# Should only return public plans
plan_names = [pv["name"] for pv in response.data]
assert "Public v1" in plan_names
assert "Private v2" not in plan_names
def test_excludes_legacy_plans(self):
"""Should exclude legacy plan versions."""
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.api.views import PlanCatalogView
# Create legacy and non-legacy plans
plan = Plan.objects.create(code="test_legacy", name="Test Legacy Plan")
current_pv = PlanVersion.objects.create(
plan=plan, version=1, name="Current v1", is_public=True, is_legacy=False
)
legacy_pv = PlanVersion.objects.create(
plan=plan, version=2, name="Legacy v2", is_public=True, is_legacy=True
)
mock_request = Mock()
view = PlanCatalogView()
view.request = mock_request
response = view.get(mock_request)
# Should only return non-legacy plans
plan_names = [pv["name"] for pv in response.data]
assert "Current v1" in plan_names
assert "Legacy v2" not in plan_names
# =============================================================================
# /api/billing/invoices/ Tests (Database required for tenant isolation)
# =============================================================================
@pytest.mark.django_db
class TestInvoicesEndpointIsolation:
"""Tests for invoice tenant isolation."""
def test_cannot_see_other_tenants_invoices(
self, clean_tenant_subscription, clean_second_tenant_subscription
):
"""A tenant should only see their own invoices."""
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.identity.users.models import User
shared_tenant = clean_tenant_subscription
second_shared_tenant = clean_second_tenant_subscription
# Create plan
plan = Plan.objects.create(code="isolation_test", name="Test Plan")
pv = PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1")
now = timezone.now()
# Create subscription for tenant 1
sub1 = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
# Create subscription for tenant 2
sub2 = Subscription.objects.create(
business=second_shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
# Create invoices for both tenants
invoice1 = Invoice.objects.create(
business=shared_tenant,
subscription=sub1,
period_start=now,
period_end=now + timedelta(days=30),
subtotal_amount=1000,
total_amount=1000,
)
invoice2 = Invoice.objects.create(
business=second_shared_tenant,
subscription=sub2,
period_start=now,
period_end=now + timedelta(days=30),
subtotal_amount=2000,
total_amount=2000,
)
# Query invoices for tenant 1 only
tenant1_invoices = Invoice.objects.filter(business=shared_tenant)
tenant2_invoices = Invoice.objects.filter(business=second_shared_tenant)
# Tenant 1 should only see their invoice
assert tenant1_invoices.count() == 1
assert tenant1_invoices.first().total_amount == 1000
# Tenant 2 should only see their invoice
assert tenant2_invoices.count() == 1
assert tenant2_invoices.first().total_amount == 2000
def test_invoice_detail_returns_404_for_other_tenant(
self, clean_tenant_subscription, clean_second_tenant_subscription
):
"""Requesting another tenant's invoice should return 404."""
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
shared_tenant = clean_tenant_subscription
second_shared_tenant = clean_second_tenant_subscription
# Create plan
plan = Plan.objects.create(code="detail_404_test", name="Test Plan")
pv = PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1")
now = timezone.now()
# Create subscription for tenant 2
sub2 = Subscription.objects.create(
business=second_shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
# Create invoice for tenant 2
invoice2 = Invoice.objects.create(
business=second_shared_tenant,
subscription=sub2,
period_start=now,
period_end=now + timedelta(days=30),
subtotal_amount=2000,
total_amount=2000,
)
# Try to get tenant 2's invoice from tenant 1's perspective
# This should return None (404 in API)
result = Invoice.objects.filter(
business=shared_tenant, id=invoice2.id
).first()
assert result is None
# =============================================================================
# /api/billing/addons/ Tests
# =============================================================================
class TestAddOnsEndpoint:
"""Tests for the /api/billing/addons/ endpoint."""
def test_returns_active_addons_only(self):
"""Should only return active add-on products."""
from smoothschedule.commerce.billing.api.views import AddOnCatalogView
mock_request = Mock()
mock_request.user = Mock()
mock_request.user.tenant = Mock()
view = AddOnCatalogView()
view.request = mock_request
with patch(
"smoothschedule.commerce.billing.api.views.AddOnProduct"
) as MockAddOn:
mock_queryset = Mock()
MockAddOn.objects.filter.return_value = mock_queryset
mock_queryset.all.return_value = []
with patch(
"smoothschedule.commerce.billing.api.views.AddOnProductSerializer"
):
view.get(mock_request)
# Verify filter was called with is_active=True
MockAddOn.objects.filter.assert_called_with(is_active=True)

View File

@@ -0,0 +1,574 @@
"""
Unit tests for EntitlementService.
These tests use mocks to avoid database overhead. The EntitlementService
resolves effective entitlements for a business by combining:
1. Base plan features (from Subscription.plan_version)
2. Add-on features (from active SubscriptionAddOns)
3. Overrides (from active EntitlementOverrides)
Resolution order (highest to lowest precedence):
1. Active, non-expired EntitlementOverrides
2. Active, non-expired SubscriptionAddOn features
3. Base PlanVersion features from Subscription
"""
from datetime import timedelta
from unittest.mock import MagicMock
from unittest.mock import Mock
from unittest.mock import patch
import pytest
from django.utils import timezone
# =============================================================================
# Helper Functions
# =============================================================================
def create_mock_feature(code: str, feature_type: str = "boolean"):
"""Create a mock Feature object."""
feature = Mock()
feature.code = code
feature.feature_type = feature_type
return feature
def create_mock_plan_feature(feature_code: str, value, feature_type: str = "boolean"):
"""Create a mock PlanFeature object."""
pf = Mock()
pf.feature = create_mock_feature(feature_code, feature_type)
if feature_type == "boolean":
pf.bool_value = value
pf.int_value = None
else:
pf.bool_value = None
pf.int_value = value
pf.get_value = Mock(return_value=value)
return pf
def create_mock_addon_feature(feature_code: str, value, feature_type: str = "boolean"):
"""Create a mock AddOnFeature object."""
af = Mock()
af.feature = create_mock_feature(feature_code, feature_type)
if feature_type == "boolean":
af.bool_value = value
af.int_value = None
else:
af.bool_value = None
af.int_value = value
af.get_value = Mock(return_value=value)
return af
def create_mock_override(
feature_code: str,
value,
feature_type: str = "boolean",
expires_at=None,
):
"""Create a mock EntitlementOverride object."""
override = Mock()
override.feature = create_mock_feature(feature_code, feature_type)
override.expires_at = expires_at
if feature_type == "boolean":
override.bool_value = value
override.int_value = None
else:
override.bool_value = None
override.int_value = value
override.get_value = Mock(return_value=value)
# Calculate is_active based on expires_at
if expires_at is None or expires_at > timezone.now():
override.is_active = True
else:
override.is_active = False
return override
def create_mock_subscription_addon(
addon_features: list,
status: str = "active",
expires_at=None,
):
"""Create a mock SubscriptionAddOn object."""
sa = Mock()
sa.status = status
sa.expires_at = expires_at
sa.addon = Mock()
sa.addon.features = Mock()
sa.addon.features.all = Mock(return_value=addon_features)
# Calculate is_active
if status == "active" and (expires_at is None or expires_at > timezone.now()):
sa.is_active = True
else:
sa.is_active = False
return sa
# =============================================================================
# get_effective_entitlements() Tests
# =============================================================================
class TestGetEffectiveEntitlements:
"""Tests for EntitlementService.get_effective_entitlements()."""
def test_returns_empty_dict_when_no_subscription(self):
"""Should return empty dict when business has no subscription."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_business.billing_subscription = None
result = EntitlementService.get_effective_entitlements(mock_business)
assert result == {}
def test_returns_base_plan_features(self):
"""Should return features from the base plan when no add-ons or overrides."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
# Set up mock subscription with plan features
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(
all=Mock(
return_value=[
create_mock_plan_feature("sms", True),
create_mock_plan_feature("max_users", 5, "integer"),
]
)
)
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(return_value=Mock(all=Mock(return_value=[])))
mock_business.billing_subscription = mock_subscription
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
assert result["sms"] is True
assert result["max_users"] == 5
def test_addon_features_stack_on_plan_features(self):
"""Add-on features should be added to the result alongside plan features."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(
all=Mock(
return_value=[
create_mock_plan_feature("sms", True),
]
)
)
)
# Add-on with additional feature
addon_with_extra = create_mock_subscription_addon(
addon_features=[
create_mock_addon_feature("advanced_reporting", True),
],
status="active",
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[addon_with_extra]))
)
mock_business.billing_subscription = mock_subscription
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
assert result["sms"] is True
assert result["advanced_reporting"] is True
def test_override_takes_precedence_over_plan_and_addon(self):
"""EntitlementOverride should override both plan and add-on values."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(
all=Mock(
return_value=[
# Plan says max_users = 5
create_mock_plan_feature("max_users", 5, "integer"),
]
)
)
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
mock_business.billing_subscription = mock_subscription
# Override gives max_users = 100
override = create_mock_override("max_users", 100, "integer")
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[override]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
# Override wins
assert result["max_users"] == 100
def test_expired_override_is_ignored(self):
"""Expired overrides should not affect the result."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(
all=Mock(
return_value=[
create_mock_plan_feature("sms", True),
]
)
)
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
mock_business.billing_subscription = mock_subscription
# Expired override that would set sms = False
expired_override = create_mock_override(
"sms",
False,
"boolean",
expires_at=timezone.now() - timedelta(days=1),
)
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[expired_override]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
# Expired override is ignored, plan value wins
assert result["sms"] is True
def test_expired_addon_is_ignored(self):
"""Expired add-ons should not affect the result."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
# Expired add-on
expired_addon = create_mock_subscription_addon(
addon_features=[
create_mock_addon_feature("advanced_reporting", True),
],
status="active",
expires_at=timezone.now() - timedelta(days=1),
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[expired_addon]))
)
mock_business.billing_subscription = mock_subscription
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
# Expired add-on is ignored
assert "advanced_reporting" not in result
def test_canceled_addon_is_ignored(self):
"""Canceled add-ons should not affect the result."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
# Canceled add-on
canceled_addon = create_mock_subscription_addon(
addon_features=[
create_mock_addon_feature("advanced_reporting", True),
],
status="canceled",
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[canceled_addon]))
)
mock_business.billing_subscription = mock_subscription
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
# Canceled add-on is ignored
assert "advanced_reporting" not in result
def test_integer_limits_highest_value_wins(self):
"""When multiple sources grant an integer feature, highest value wins."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "active"
mock_subscription.is_active = True
mock_subscription.plan_version = Mock()
mock_subscription.plan_version.features = Mock()
mock_subscription.plan_version.features.select_related = Mock(
return_value=Mock(
all=Mock(
return_value=[
# Plan gives 10 users
create_mock_plan_feature("max_users", 10, "integer"),
]
)
)
)
# Add-on gives 25 more users
addon = create_mock_subscription_addon(
addon_features=[
create_mock_addon_feature("max_users", 25, "integer"),
],
status="active",
)
mock_subscription.addons = Mock()
mock_subscription.addons.filter = Mock(
return_value=Mock(all=Mock(return_value=[addon]))
)
mock_business.billing_subscription = mock_subscription
mock_business.entitlement_overrides = Mock()
mock_business.entitlement_overrides.select_related = Mock(
return_value=Mock(all=Mock(return_value=[]))
)
result = EntitlementService.get_effective_entitlements(mock_business)
# Highest value wins (25 > 10)
assert result["max_users"] == 25
def test_returns_empty_when_subscription_not_active(self):
"""Should return empty dict when subscription is not active."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
mock_subscription = Mock()
mock_subscription.status = "canceled"
mock_subscription.is_active = False
mock_business.billing_subscription = mock_subscription
result = EntitlementService.get_effective_entitlements(mock_business)
assert result == {}
# =============================================================================
# has_feature() Tests
# =============================================================================
class TestHasFeature:
"""Tests for EntitlementService.has_feature()."""
def test_returns_true_for_enabled_boolean_feature(self):
"""has_feature should return True when feature is enabled."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"sms": True, "email": True},
):
assert EntitlementService.has_feature(mock_business, "sms") is True
assert EntitlementService.has_feature(mock_business, "email") is True
def test_returns_false_for_disabled_boolean_feature(self):
"""has_feature should return False when feature is disabled."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"sms": False},
):
assert EntitlementService.has_feature(mock_business, "sms") is False
def test_returns_false_for_missing_feature(self):
"""has_feature should return False when feature is not in entitlements."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"sms": True},
):
assert EntitlementService.has_feature(mock_business, "webhooks") is False
def test_returns_true_for_non_zero_integer_feature(self):
"""has_feature should return True for non-zero integer limits."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"max_users": 10},
):
assert EntitlementService.has_feature(mock_business, "max_users") is True
def test_returns_false_for_zero_integer_feature(self):
"""has_feature should return False for zero integer limits."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"max_users": 0},
):
assert EntitlementService.has_feature(mock_business, "max_users") is False
# =============================================================================
# get_limit() Tests
# =============================================================================
class TestGetLimit:
"""Tests for EntitlementService.get_limit()."""
def test_returns_integer_value(self):
"""get_limit should return the integer value for integer features."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"max_users": 50, "max_resources": 100},
):
assert EntitlementService.get_limit(mock_business, "max_users") == 50
assert EntitlementService.get_limit(mock_business, "max_resources") == 100
def test_returns_none_for_missing_feature(self):
"""get_limit should return None for missing features."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"max_users": 50},
):
assert EntitlementService.get_limit(mock_business, "max_widgets") is None
def test_returns_none_for_boolean_feature(self):
"""get_limit should return None for boolean features."""
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
mock_business = Mock()
with patch.object(
EntitlementService,
"get_effective_entitlements",
return_value={"sms": True},
):
# Boolean values should not be treated as limits
result = EntitlementService.get_limit(mock_business, "sms")
assert result is None

View File

@@ -0,0 +1,331 @@
"""
Unit tests for Invoice generation service.
Tests verify that:
- Invoices are created with correct snapshot values
- Line items are created for plan and add-ons
- Changing PlanVersion pricing does NOT affect existing invoices
- Totals are calculated correctly
"""
from datetime import timedelta
from decimal import Decimal
from unittest.mock import Mock
from unittest.mock import patch
import pytest
from django.utils import timezone
@pytest.fixture
def clean_tenant_subscription(shared_tenant):
"""Delete any existing subscription for shared_tenant before test."""
from smoothschedule.commerce.billing.models import Subscription
Subscription.objects.filter(business=shared_tenant).delete()
yield shared_tenant
# =============================================================================
# generate_invoice_for_subscription() Tests
# =============================================================================
@pytest.mark.django_db
class TestGenerateInvoiceForSubscription:
"""Tests for the invoice generation service."""
def test_creates_invoice_with_plan_snapshots(self, clean_tenant_subscription):
"""Invoice should capture plan name and code at billing time."""
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
# Create plan and subscription
plan = Plan.objects.create(code="pro", name="Pro")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Pro Plan v1", price_monthly_cents=2999
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
period_start = now
period_end = now + timedelta(days=30)
invoice = generate_invoice_for_subscription(
subscription, period_start, period_end
)
# Verify snapshot values
assert invoice.plan_code_at_billing == "pro"
assert invoice.plan_name_at_billing == "Pro Plan v1"
assert invoice.plan_version_id_at_billing == pv.id
def test_creates_line_item_for_base_plan(self, clean_tenant_subscription):
"""Invoice should have a line item for the base plan subscription."""
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
plan = Plan.objects.create(code="starter_line", name="Starter")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Starter Plan", price_monthly_cents=999
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
period_start = now
period_end = now + timedelta(days=30)
invoice = generate_invoice_for_subscription(
subscription, period_start, period_end
)
# Verify line item for plan
lines = list(invoice.lines.all())
assert len(lines) == 1
assert lines[0].line_type == "plan"
assert lines[0].unit_amount == 999
assert "Starter Plan" in lines[0].description
def test_creates_line_items_for_active_addons(self, clean_tenant_subscription):
"""Invoice should have line items for each active add-on."""
from smoothschedule.commerce.billing.models import AddOnProduct
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.models import SubscriptionAddOn
from smoothschedule.commerce.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
plan = Plan.objects.create(code="pro_addon", name="Pro")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Pro Plan", price_monthly_cents=2999
)
addon = AddOnProduct.objects.create(
code="sms_pack", name="SMS Pack", price_monthly_cents=500
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
SubscriptionAddOn.objects.create(
subscription=subscription, addon=addon, status="active"
)
period_start = now
period_end = now + timedelta(days=30)
invoice = generate_invoice_for_subscription(
subscription, period_start, period_end
)
# Should have 2 lines - plan and add-on
lines = list(invoice.lines.all())
assert len(lines) == 2
addon_line = [l for l in lines if l.line_type == "addon"][0]
assert addon_line.unit_amount == 500
assert "SMS Pack" in addon_line.description
def test_calculates_totals_correctly(self, clean_tenant_subscription):
"""Invoice totals should be calculated from line items."""
from smoothschedule.commerce.billing.models import AddOnProduct
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.models import SubscriptionAddOn
from smoothschedule.commerce.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
plan = Plan.objects.create(code="pro_totals", name="Pro")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Pro Plan", price_monthly_cents=2999
)
addon = AddOnProduct.objects.create(
code="sms_pack_totals", name="SMS Pack", price_monthly_cents=500
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
SubscriptionAddOn.objects.create(
subscription=subscription, addon=addon, status="active"
)
period_start = now
period_end = now + timedelta(days=30)
invoice = generate_invoice_for_subscription(
subscription, period_start, period_end
)
# Total should be plan + addon = 2999 + 500 = 3499 cents
assert invoice.subtotal_amount == 3499
assert invoice.total_amount == 3499
def test_skips_inactive_addons(self, clean_tenant_subscription):
"""Inactive add-ons should not be included in the invoice."""
from smoothschedule.commerce.billing.models import AddOnProduct
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
from smoothschedule.commerce.billing.models import SubscriptionAddOn
from smoothschedule.commerce.billing.services.invoicing import (
generate_invoice_for_subscription,
)
shared_tenant = clean_tenant_subscription
plan = Plan.objects.create(code="pro_inactive", name="Pro")
pv = PlanVersion.objects.create(
plan=plan, version=1, name="Pro Plan", price_monthly_cents=2999
)
addon = AddOnProduct.objects.create(
code="sms_pack_inactive", name="SMS Pack", price_monthly_cents=500
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
# Create canceled add-on
SubscriptionAddOn.objects.create(
subscription=subscription, addon=addon, status="canceled"
)
period_start = now
period_end = now + timedelta(days=30)
invoice = generate_invoice_for_subscription(
subscription, period_start, period_end
)
# Should only have plan line, not the canceled add-on
lines = list(invoice.lines.all())
assert len(lines) == 1
assert lines[0].line_type == "plan"
# =============================================================================
# Invoice Immutability Tests (Database required)
# =============================================================================
@pytest.mark.django_db
class TestInvoiceImmutability:
"""Tests that verify invoice immutability."""
def test_changing_plan_price_does_not_affect_existing_invoice(
self, clean_tenant_subscription
):
"""
Changing a PlanVersion's price should NOT affect existing invoices.
This verifies the snapshot design.
"""
from smoothschedule.commerce.billing.models import Invoice
from smoothschedule.commerce.billing.models import InvoiceLine
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
from smoothschedule.commerce.billing.models import Subscription
shared_tenant = clean_tenant_subscription
# Create plan and subscription
plan = Plan.objects.create(code="test_immutable", name="Test Plan")
pv = PlanVersion.objects.create(
plan=plan,
version=1,
name="Test Plan v1",
price_monthly_cents=1000, # $10.00
)
now = timezone.now()
subscription = Subscription.objects.create(
business=shared_tenant,
plan_version=pv,
status="active",
current_period_start=now,
current_period_end=now + timedelta(days=30),
)
# Create invoice with snapshot
invoice = Invoice.objects.create(
business=shared_tenant,
subscription=subscription,
period_start=now,
period_end=now + timedelta(days=30),
plan_code_at_billing="test_immutable",
plan_name_at_billing="Test Plan v1",
plan_version_id_at_billing=pv.id,
subtotal_amount=1000,
total_amount=1000,
status="paid",
)
# Create line item
InvoiceLine.objects.create(
invoice=invoice,
line_type="plan",
description="Test Plan v1",
quantity=1,
unit_amount=1000,
subtotal_amount=1000,
total_amount=1000,
plan_version=pv,
)
# Now change the plan price
pv.price_monthly_cents = 2000 # Changed to $20.00!
pv.save()
# Refresh invoice from DB
invoice.refresh_from_db()
# Invoice should still show the original amounts
assert invoice.subtotal_amount == 1000
assert invoice.total_amount == 1000
assert invoice.plan_name_at_billing == "Test Plan v1"
# Line item should also be unchanged
line = invoice.lines.first()
assert line.unit_amount == 1000
assert line.total_amount == 1000

View File

@@ -0,0 +1,586 @@
"""
Unit tests for billing models.
These tests use mocks to avoid database overhead. Only use @pytest.mark.django_db
for tests that require actual database operations (constraints, migrations).
"""
from datetime import timedelta
from unittest.mock import Mock
import pytest
from django.utils import timezone
# =============================================================================
# Feature Model Tests
# =============================================================================
class TestFeatureModel:
"""Tests for the Feature model."""
def test_feature_has_required_fields(self):
"""Feature model should have code, name, feature_type fields."""
from smoothschedule.commerce.billing.models import Feature
# Check model has expected fields
field_names = [f.name for f in Feature._meta.get_fields()]
assert "code" in field_names
assert "name" in field_names
assert "feature_type" in field_names
assert "description" in field_names
assert "created_at" in field_names
def test_feature_type_choices(self):
"""Feature should support boolean and integer types."""
from smoothschedule.commerce.billing.models import Feature
feature = Feature(
code="test_feature",
name="Test Feature",
feature_type="boolean",
)
assert feature.feature_type == "boolean"
feature2 = Feature(
code="test_limit",
name="Test Limit",
feature_type="integer",
)
assert feature2.feature_type == "integer"
def test_feature_str_representation(self):
"""Feature __str__ should return the feature name."""
from smoothschedule.commerce.billing.models import Feature
feature = Feature(code="sms", name="SMS Notifications")
assert str(feature) == "SMS Notifications"
# =============================================================================
# Plan Model Tests
# =============================================================================
class TestPlanModel:
"""Tests for the Plan model (logical plan grouping)."""
def test_plan_has_required_fields(self):
"""Plan model should have code, name, display_order, is_active fields."""
from smoothschedule.commerce.billing.models import Plan
field_names = [f.name for f in Plan._meta.get_fields()]
assert "code" in field_names
assert "name" in field_names
assert "display_order" in field_names
assert "is_active" in field_names
def test_plan_str_representation(self):
"""Plan __str__ should return the plan name."""
from smoothschedule.commerce.billing.models import Plan
plan = Plan(code="pro", name="Pro Plan")
assert str(plan) == "Pro Plan"
def test_plan_default_values(self):
"""Plan should have sensible defaults."""
from smoothschedule.commerce.billing.models import Plan
plan = Plan(code="starter", name="Starter")
assert plan.is_active is True
assert plan.display_order == 0
# =============================================================================
# PlanVersion Model Tests
# =============================================================================
class TestPlanVersionModel:
"""Tests for the PlanVersion model (concrete offer)."""
def test_plan_version_has_required_fields(self):
"""PlanVersion should have pricing, visibility, and Stripe fields."""
from smoothschedule.commerce.billing.models import PlanVersion
field_names = [f.name for f in PlanVersion._meta.get_fields()]
assert "plan" in field_names
assert "version" in field_names
assert "name" in field_names
assert "is_public" in field_names
assert "is_legacy" in field_names
assert "starts_at" in field_names
assert "ends_at" in field_names
assert "price_monthly_cents" in field_names
assert "price_yearly_cents" in field_names
assert "stripe_product_id" in field_names
assert "stripe_price_id_monthly" in field_names
assert "stripe_price_id_yearly" in field_names
def test_plan_version_str_representation(self):
"""PlanVersion __str__ should return the version name."""
from smoothschedule.commerce.billing.models import PlanVersion
pv = PlanVersion(name="Pro Plan - 2024 Holiday Promo")
# Don't need to set plan for __str__ - it just uses name
assert str(pv) == "Pro Plan - 2024 Holiday Promo"
def test_plan_version_is_available_when_public_and_no_date_constraints(self):
"""PlanVersion.is_available should return True for public versions with no dates."""
from smoothschedule.commerce.billing.models import PlanVersion
pv = PlanVersion(is_public=True, is_legacy=False, starts_at=None, ends_at=None)
assert pv.is_available is True
def test_plan_version_is_not_available_when_legacy(self):
"""Legacy versions should not be available for new signups."""
from smoothschedule.commerce.billing.models import PlanVersion
pv = PlanVersion(is_public=True, is_legacy=True, starts_at=None, ends_at=None)
assert pv.is_available is False
def test_plan_version_is_not_available_when_not_public(self):
"""Non-public versions should not be available."""
from smoothschedule.commerce.billing.models import PlanVersion
pv = PlanVersion(is_public=False, is_legacy=False, starts_at=None, ends_at=None)
assert pv.is_available is False
def test_plan_version_is_available_within_date_window(self):
"""PlanVersion should be available within its date window."""
from smoothschedule.commerce.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
is_public=True,
is_legacy=False,
starts_at=now - timedelta(days=1),
ends_at=now + timedelta(days=1),
)
assert pv.is_available is True
def test_plan_version_is_not_available_before_start_date(self):
"""PlanVersion should not be available before its start date."""
from smoothschedule.commerce.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
is_public=True,
is_legacy=False,
starts_at=now + timedelta(days=1),
ends_at=None,
)
assert pv.is_available is False
def test_plan_version_is_not_available_after_end_date(self):
"""PlanVersion should not be available after its end date."""
from smoothschedule.commerce.billing.models import PlanVersion
now = timezone.now()
pv = PlanVersion(
is_public=True,
is_legacy=False,
starts_at=None,
ends_at=now - timedelta(days=1),
)
assert pv.is_available is False
# =============================================================================
# PlanFeature Model Tests
# =============================================================================
class TestPlanFeatureModel:
"""Tests for the PlanFeature model (plan -> feature mapping)."""
def test_plan_feature_has_required_fields(self):
"""PlanFeature should have plan_version, feature, and value fields."""
from smoothschedule.commerce.billing.models import PlanFeature
field_names = [f.name for f in PlanFeature._meta.get_fields()]
assert "plan_version" in field_names
assert "feature" in field_names
assert "bool_value" in field_names
assert "int_value" in field_names
@pytest.mark.django_db
def test_plan_feature_get_value_returns_bool(self):
"""get_value should return bool_value for boolean features."""
from smoothschedule.commerce.billing.models import Feature
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanFeature
from smoothschedule.commerce.billing.models import PlanVersion
# Create real instances since Django ForeignKey doesn't accept Mock
feature = Feature.objects.create(
code="test_bool_feature",
name="Test Bool Feature",
feature_type="boolean",
)
plan = Plan.objects.create(code="test_plan", name="Test Plan")
pv = PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1")
pf = PlanFeature(plan_version=pv, feature=feature, bool_value=True, int_value=None)
assert pf.get_value() is True
@pytest.mark.django_db
def test_plan_feature_get_value_returns_int(self):
"""get_value should return int_value for integer features."""
from smoothschedule.commerce.billing.models import Feature
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanFeature
from smoothschedule.commerce.billing.models import PlanVersion
feature = Feature.objects.create(
code="test_int_feature",
name="Test Int Feature",
feature_type="integer",
)
plan = Plan.objects.create(code="test_plan2", name="Test Plan 2")
pv = PlanVersion.objects.create(plan=plan, version=1, name="Test Plan 2 v1")
pf = PlanFeature(plan_version=pv, feature=feature, bool_value=None, int_value=100)
assert pf.get_value() == 100
# =============================================================================
# Subscription Model Tests
# =============================================================================
class TestSubscriptionModel:
"""Tests for the Subscription model."""
def test_subscription_has_required_fields(self):
"""Subscription should have business, plan_version, status, dates, etc."""
from smoothschedule.commerce.billing.models import Subscription
field_names = [f.name for f in Subscription._meta.get_fields()]
assert "business" in field_names
assert "plan_version" in field_names
assert "status" in field_names
assert "started_at" in field_names
assert "current_period_start" in field_names
assert "current_period_end" in field_names
assert "trial_ends_at" in field_names
assert "canceled_at" in field_names
assert "stripe_subscription_id" in field_names
def test_subscription_status_choices(self):
"""Subscription should support trial, active, past_due, canceled statuses."""
from smoothschedule.commerce.billing.models import Subscription
valid_statuses = ["trial", "active", "past_due", "canceled"]
for status in valid_statuses:
sub = Subscription(status=status)
assert sub.status == status
def test_subscription_is_active_when_status_is_active(self):
"""is_active property should return True for active subscriptions."""
from smoothschedule.commerce.billing.models import Subscription
sub = Subscription(status="active")
assert sub.is_active is True
def test_subscription_is_active_when_status_is_trial(self):
"""Trial subscriptions should be considered active."""
from smoothschedule.commerce.billing.models import Subscription
sub = Subscription(status="trial")
assert sub.is_active is True
def test_subscription_is_not_active_when_canceled(self):
"""Canceled subscriptions should not be considered active."""
from smoothschedule.commerce.billing.models import Subscription
sub = Subscription(status="canceled")
assert sub.is_active is False
def test_subscription_is_not_active_when_past_due(self):
"""Past due subscriptions should not be considered active."""
from smoothschedule.commerce.billing.models import Subscription
sub = Subscription(status="past_due")
assert sub.is_active is False
# =============================================================================
# AddOnProduct Model Tests
# =============================================================================
class TestAddOnProductModel:
"""Tests for the AddOnProduct model."""
def test_addon_has_required_fields(self):
"""AddOnProduct should have code, name, pricing, Stripe fields."""
from smoothschedule.commerce.billing.models import AddOnProduct
field_names = [f.name for f in AddOnProduct._meta.get_fields()]
assert "code" in field_names
assert "name" in field_names
assert "description" in field_names
assert "price_monthly_cents" in field_names
assert "price_one_time_cents" in field_names
assert "stripe_product_id" in field_names
assert "stripe_price_id" in field_names
assert "is_active" in field_names
def test_addon_str_representation(self):
"""AddOnProduct __str__ should return the addon name."""
from smoothschedule.commerce.billing.models import AddOnProduct
addon = AddOnProduct(code="sms_pack", name="SMS Pack (1000)")
assert str(addon) == "SMS Pack (1000)"
# =============================================================================
# AddOnFeature Model Tests
# =============================================================================
class TestAddOnFeatureModel:
"""Tests for the AddOnFeature model."""
def test_addon_feature_has_required_fields(self):
"""AddOnFeature should have addon, feature, and value fields."""
from smoothschedule.commerce.billing.models import AddOnFeature
field_names = [f.name for f in AddOnFeature._meta.get_fields()]
assert "addon" in field_names
assert "feature" in field_names
assert "bool_value" in field_names
assert "int_value" in field_names
# =============================================================================
# SubscriptionAddOn Model Tests
# =============================================================================
class TestSubscriptionAddOnModel:
"""Tests for the SubscriptionAddOn model."""
def test_subscription_addon_has_required_fields(self):
"""SubscriptionAddOn should have subscription, addon, status, dates."""
from smoothschedule.commerce.billing.models import SubscriptionAddOn
field_names = [f.name for f in SubscriptionAddOn._meta.get_fields()]
assert "subscription" in field_names
assert "addon" in field_names
assert "status" in field_names
assert "activated_at" in field_names
assert "expires_at" in field_names
assert "canceled_at" in field_names
assert "stripe_subscription_item_id" in field_names
def test_subscription_addon_is_active_when_status_active_no_expiry(self):
"""is_active should return True for active add-ons without expiry."""
from smoothschedule.commerce.billing.models import SubscriptionAddOn
sa = SubscriptionAddOn(status="active", expires_at=None)
assert sa.is_active is True
def test_subscription_addon_is_active_when_status_active_future_expiry(self):
"""is_active should return True for active add-ons with future expiry."""
from smoothschedule.commerce.billing.models import SubscriptionAddOn
future = timezone.now() + timedelta(days=30)
sa = SubscriptionAddOn(status="active", expires_at=future)
assert sa.is_active is True
def test_subscription_addon_is_not_active_when_expired(self):
"""is_active should return False for expired add-ons."""
from smoothschedule.commerce.billing.models import SubscriptionAddOn
past = timezone.now() - timedelta(days=1)
sa = SubscriptionAddOn(status="active", expires_at=past)
assert sa.is_active is False
def test_subscription_addon_is_not_active_when_canceled(self):
"""is_active should return False for canceled add-ons."""
from smoothschedule.commerce.billing.models import SubscriptionAddOn
sa = SubscriptionAddOn(status="canceled", expires_at=None)
assert sa.is_active is False
# =============================================================================
# EntitlementOverride Model Tests
# =============================================================================
class TestEntitlementOverrideModel:
"""Tests for the EntitlementOverride model."""
def test_override_has_required_fields(self):
"""EntitlementOverride should have business, feature, source, value fields."""
from smoothschedule.commerce.billing.models import EntitlementOverride
field_names = [f.name for f in EntitlementOverride._meta.get_fields()]
assert "business" in field_names
assert "feature" in field_names
assert "source" in field_names
assert "bool_value" in field_names
assert "int_value" in field_names
assert "reason" in field_names
assert "granted_by" in field_names
assert "expires_at" in field_names
assert "created_at" in field_names
def test_override_source_choices(self):
"""EntitlementOverride should support manual, promo, support sources."""
from smoothschedule.commerce.billing.models import EntitlementOverride
valid_sources = ["manual", "promo", "support"]
for source in valid_sources:
override = EntitlementOverride(source=source)
assert override.source == source
def test_override_is_active_when_no_expiry(self):
"""is_active should return True for overrides without expiry."""
from smoothschedule.commerce.billing.models import EntitlementOverride
override = EntitlementOverride(expires_at=None)
assert override.is_active is True
def test_override_is_active_when_future_expiry(self):
"""is_active should return True for overrides with future expiry."""
from smoothschedule.commerce.billing.models import EntitlementOverride
future = timezone.now() + timedelta(days=30)
override = EntitlementOverride(expires_at=future)
assert override.is_active is True
def test_override_is_not_active_when_expired(self):
"""is_active should return False for expired overrides."""
from smoothschedule.commerce.billing.models import EntitlementOverride
past = timezone.now() - timedelta(days=1)
override = EntitlementOverride(expires_at=past)
assert override.is_active is False
@pytest.mark.django_db
def test_override_get_value_returns_bool(self):
"""get_value should return bool_value for boolean features."""
from smoothschedule.commerce.billing.models import EntitlementOverride
from smoothschedule.commerce.billing.models import Feature
feature = Feature.objects.create(
code="override_test_bool",
name="Override Test Bool",
feature_type="boolean",
)
override = EntitlementOverride(feature=feature, bool_value=True, int_value=None)
assert override.get_value() is True
@pytest.mark.django_db
def test_override_get_value_returns_int(self):
"""get_value should return int_value for integer features."""
from smoothschedule.commerce.billing.models import EntitlementOverride
from smoothschedule.commerce.billing.models import Feature
feature = Feature.objects.create(
code="override_test_int",
name="Override Test Int",
feature_type="integer",
)
override = EntitlementOverride(feature=feature, bool_value=None, int_value=500)
assert override.get_value() == 500
# =============================================================================
# Database Constraint Tests (require @pytest.mark.django_db)
# =============================================================================
@pytest.mark.django_db
class TestModelConstraints:
"""Tests that verify database constraints. These require actual DB access."""
def test_feature_code_is_unique(self):
"""Feature.code should be unique."""
import uuid
from django.db import IntegrityError
from smoothschedule.commerce.billing.models import Feature
unique_code = f"test_feature_{uuid.uuid4().hex[:8]}"
Feature.objects.create(code=unique_code, name="Test Feature", feature_type="boolean")
with pytest.raises(IntegrityError):
Feature.objects.create(code=unique_code, name="Test Feature 2", feature_type="boolean")
def test_plan_code_is_unique(self):
"""Plan.code should be unique."""
import uuid
from django.db import IntegrityError
from smoothschedule.commerce.billing.models import Plan
unique_code = f"test_plan_{uuid.uuid4().hex[:8]}"
Plan.objects.create(code=unique_code, name="Test Plan")
with pytest.raises(IntegrityError):
Plan.objects.create(code=unique_code, name="Test Plan 2")
def test_plan_version_unique_together(self):
"""PlanVersion (plan, version) should be unique together."""
import uuid
from django.db import IntegrityError
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanVersion
unique_code = f"test_plan_{uuid.uuid4().hex[:8]}"
plan = Plan.objects.create(code=unique_code, name="Test Plan")
PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1")
with pytest.raises(IntegrityError):
PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1 duplicate")
def test_plan_feature_unique_together(self):
"""PlanFeature (plan_version, feature) should be unique together."""
import uuid
from django.db import IntegrityError
from smoothschedule.commerce.billing.models import Feature
from smoothschedule.commerce.billing.models import Plan
from smoothschedule.commerce.billing.models import PlanFeature
from smoothschedule.commerce.billing.models import PlanVersion
unique_plan_code = f"test_plan_{uuid.uuid4().hex[:8]}"
unique_feature_code = f"test_feature_{uuid.uuid4().hex[:8]}"
plan = Plan.objects.create(code=unique_plan_code, name="Test Plan")
pv = PlanVersion.objects.create(plan=plan, version=1, name="Test Plan v1")
feature = Feature.objects.create(code=unique_feature_code, name="Test Feature", feature_type="boolean")
PlanFeature.objects.create(plan_version=pv, feature=feature, bool_value=True)
with pytest.raises(IntegrityError):
PlanFeature.objects.create(
plan_version=pv, feature=feature, bool_value=False
)
def test_addon_code_is_unique(self):
"""AddOnProduct.code should be unique."""
import uuid
from django.db import IntegrityError
from smoothschedule.commerce.billing.models import AddOnProduct
unique_code = f"test_addon_{uuid.uuid4().hex[:8]}"
AddOnProduct.objects.create(code=unique_code, name="Test Addon")
with pytest.raises(IntegrityError):
AddOnProduct.objects.create(code=unique_code, name="Test Addon 2")

View File

@@ -417,8 +417,10 @@ class Tenant(TenantMixin):
""" """
Check if this tenant has a specific feature permission. Check if this tenant has a specific feature permission.
Checks both the boolean field on the Tenant model and the subscription plan's Resolution order (highest to lowest precedence):
permissions JSON field. 1. Direct boolean field on Tenant model (e.g., can_white_label)
2. New billing EntitlementService (if billing_subscription exists)
3. Legacy subscription_plan.permissions JSON field (fallback)
Args: Args:
permission_key: The permission key to check (e.g., 'can_use_sms_reminders', permission_key: The permission key to check (e.g., 'can_use_sms_reminders',
@@ -431,7 +433,14 @@ class Tenant(TenantMixin):
if hasattr(self, permission_key): if hasattr(self, permission_key):
return bool(getattr(self, permission_key)) return bool(getattr(self, permission_key))
# If tenant has a subscription plan, check its permissions # Check new billing EntitlementService if billing_subscription exists
if hasattr(self, 'billing_subscription') and self.billing_subscription:
from smoothschedule.commerce.billing.services.entitlements import (
EntitlementService,
)
return EntitlementService.has_feature(self, permission_key)
# Fallback: If tenant has a legacy subscription plan, check its permissions
if hasattr(self, 'subscription_plan') and self.subscription_plan: if hasattr(self, 'subscription_plan') and self.subscription_plan:
plan_permissions = self.subscription_plan.permissions or {} plan_permissions = self.subscription_plan.permissions or {}
return bool(plan_permissions.get(permission_key, False)) return bool(plan_permissions.get(permission_key, False))