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:
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
212
frontend/src/api/__tests__/billing.test.ts
Normal file
212
frontend/src/api/__tests__/billing.test.ts
Normal 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
184
frontend/src/api/billing.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
247
frontend/src/components/FeatureGate.tsx
Normal file
247
frontend/src/components/FeatureGate.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* FeatureGate Component
|
||||||
|
*
|
||||||
|
* Conditionally renders children based on entitlement checks.
|
||||||
|
* Used to show/hide features based on the business's subscription plan.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useEntitlements } from '../hooks/useEntitlements';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FeatureGate - For boolean feature checks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface FeatureGateProps {
|
||||||
|
/**
|
||||||
|
* Single feature code to check
|
||||||
|
*/
|
||||||
|
feature?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiple feature codes to check
|
||||||
|
*/
|
||||||
|
features?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, ALL features must be enabled. If false, ANY feature being enabled is sufficient.
|
||||||
|
* Default: true (all required)
|
||||||
|
*/
|
||||||
|
requireAll?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render when feature(s) are enabled
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render when feature(s) are NOT enabled
|
||||||
|
*/
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render while entitlements are loading
|
||||||
|
*/
|
||||||
|
loadingFallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditionally render content based on feature entitlements.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Single feature check
|
||||||
|
* <FeatureGate feature="can_use_sms_reminders">
|
||||||
|
* <SMSSettings />
|
||||||
|
* </FeatureGate>
|
||||||
|
*
|
||||||
|
* // With fallback
|
||||||
|
* <FeatureGate
|
||||||
|
* feature="can_use_sms_reminders"
|
||||||
|
* fallback={<UpgradePrompt feature="SMS Reminders" />}
|
||||||
|
* >
|
||||||
|
* <SMSSettings />
|
||||||
|
* </FeatureGate>
|
||||||
|
*
|
||||||
|
* // Multiple features (all required)
|
||||||
|
* <FeatureGate features={['can_use_plugins', 'can_use_tasks']}>
|
||||||
|
* <TaskScheduler />
|
||||||
|
* </FeatureGate>
|
||||||
|
*
|
||||||
|
* // Multiple features (any one)
|
||||||
|
* <FeatureGate features={['can_use_sms_reminders', 'can_use_webhooks']} requireAll={false}>
|
||||||
|
* <NotificationSettings />
|
||||||
|
* </FeatureGate>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const FeatureGate: React.FC<FeatureGateProps> = ({
|
||||||
|
feature,
|
||||||
|
features,
|
||||||
|
requireAll = true,
|
||||||
|
children,
|
||||||
|
fallback = null,
|
||||||
|
loadingFallback = null,
|
||||||
|
}) => {
|
||||||
|
const { hasFeature, isLoading } = useEntitlements();
|
||||||
|
|
||||||
|
// Show loading state if provided
|
||||||
|
if (isLoading) {
|
||||||
|
return <>{loadingFallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which features to check
|
||||||
|
const featuresToCheck = features ?? (feature ? [feature] : []);
|
||||||
|
|
||||||
|
if (featuresToCheck.length === 0) {
|
||||||
|
// No features specified, render children
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check features
|
||||||
|
const hasAccess = requireAll
|
||||||
|
? featuresToCheck.every((f) => hasFeature(f))
|
||||||
|
: featuresToCheck.some((f) => hasFeature(f));
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{fallback}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LimitGate - For integer limit checks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface LimitGateProps {
|
||||||
|
/**
|
||||||
|
* The limit feature code to check (e.g., 'max_users')
|
||||||
|
*/
|
||||||
|
limit: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current usage count
|
||||||
|
*/
|
||||||
|
currentUsage: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render when under the limit
|
||||||
|
*/
|
||||||
|
children: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render when at or over the limit
|
||||||
|
*/
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content to render while entitlements are loading
|
||||||
|
*/
|
||||||
|
loadingFallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditionally render content based on usage limits.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LimitGate
|
||||||
|
* limit="max_users"
|
||||||
|
* currentUsage={users.length}
|
||||||
|
* fallback={<UpgradePrompt message="You've reached your user limit" />}
|
||||||
|
* >
|
||||||
|
* <AddUserButton />
|
||||||
|
* </LimitGate>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const LimitGate: React.FC<LimitGateProps> = ({
|
||||||
|
limit,
|
||||||
|
currentUsage,
|
||||||
|
children,
|
||||||
|
fallback = null,
|
||||||
|
loadingFallback = null,
|
||||||
|
}) => {
|
||||||
|
const { getLimit, isLoading } = useEntitlements();
|
||||||
|
|
||||||
|
// Show loading state if provided
|
||||||
|
if (isLoading) {
|
||||||
|
return <>{loadingFallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLimit = getLimit(limit);
|
||||||
|
|
||||||
|
// If limit is null, treat as unlimited
|
||||||
|
if (maxLimit === null) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if under limit
|
||||||
|
if (currentUsage < maxLimit) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{fallback}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Components
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface UpgradePromptProps {
|
||||||
|
/**
|
||||||
|
* Feature name to display
|
||||||
|
*/
|
||||||
|
feature?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom message
|
||||||
|
*/
|
||||||
|
message?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade URL (defaults to /settings/billing)
|
||||||
|
*/
|
||||||
|
upgradeUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default upgrade prompt component.
|
||||||
|
* Can be used as a fallback in FeatureGate/LimitGate.
|
||||||
|
*/
|
||||||
|
export const UpgradePrompt: React.FC<UpgradePromptProps> = ({
|
||||||
|
feature,
|
||||||
|
message,
|
||||||
|
upgradeUrl = '/settings/billing',
|
||||||
|
}) => {
|
||||||
|
const displayMessage =
|
||||||
|
message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-yellow-600 dark:text-yellow-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-yellow-800 dark:text-yellow-200 font-medium">{displayMessage}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={upgradeUrl}
|
||||||
|
className="mt-2 inline-block text-sm text-yellow-700 dark:text-yellow-300 hover:underline"
|
||||||
|
>
|
||||||
|
View upgrade options →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureGate;
|
||||||
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
270
frontend/src/components/__tests__/FeatureGate.test.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FeatureGate component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { FeatureGate, LimitGate } from '../FeatureGate';
|
||||||
|
import * as useEntitlementsModule from '../../hooks/useEntitlements';
|
||||||
|
|
||||||
|
// Mock the useEntitlements hook
|
||||||
|
vi.mock('../../hooks/useEntitlements', () => ({
|
||||||
|
useEntitlements: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FeatureGate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when feature is enabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: true },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('SMS Feature Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when feature is disabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: false },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback when feature is disabled', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { can_use_sms_reminders: false },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
feature="can_use_sms_reminders"
|
||||||
|
fallback={<div>Upgrade to access SMS</div>}
|
||||||
|
>
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upgrade to access SMS')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while loading', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate feature="can_use_sms_reminders">
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading component when provided and loading', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: true,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
feature="can_use_sms_reminders"
|
||||||
|
loadingFallback={<div>Loading...</div>}
|
||||||
|
>
|
||||||
|
<div>SMS Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('SMS Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks multiple features with requireAll=true', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||||
|
requireAll={true}
|
||||||
|
>
|
||||||
|
<div>Multi Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not render because mobile_app is disabled
|
||||||
|
expect(screen.queryByText('Multi Feature Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks multiple features with requireAll=false (any)', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: (key: string) => key === 'can_use_sms_reminders',
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FeatureGate
|
||||||
|
features={['can_use_sms_reminders', 'can_use_mobile_app']}
|
||||||
|
requireAll={false}
|
||||||
|
>
|
||||||
|
<div>Multi Feature Content</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should render because at least one (sms) is enabled
|
||||||
|
expect(screen.getByText('Multi Feature Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LimitGate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when under limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={5}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Under Limit Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when at limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={10}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render children when over limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={15}>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders fallback when over limit', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: { max_users: 10 },
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: (key: string) => (key === 'max_users' ? 10 : null),
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate
|
||||||
|
limit="max_users"
|
||||||
|
currentUsage={15}
|
||||||
|
fallback={<div>Upgrade for more users</div>}
|
||||||
|
>
|
||||||
|
<div>Under Limit Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Under Limit Content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Upgrade for more users')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children when limit is null (unlimited)', () => {
|
||||||
|
vi.mocked(useEntitlementsModule.useEntitlements).mockReturnValue({
|
||||||
|
entitlements: {},
|
||||||
|
isLoading: false,
|
||||||
|
hasFeature: () => false,
|
||||||
|
getLimit: () => null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<LimitGate limit="max_users" currentUsage={1000}>
|
||||||
|
<div>Unlimited Content</div>
|
||||||
|
</LimitGate>
|
||||||
|
);
|
||||||
|
|
||||||
|
// When limit is null, treat as unlimited
|
||||||
|
expect(screen.getByText('Unlimited Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
184
frontend/src/hooks/__tests__/useEntitlements.test.tsx
Normal file
184
frontend/src/hooks/__tests__/useEntitlements.test.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useEntitlements hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import React from 'react';
|
||||||
|
import { useEntitlements } from '../useEntitlements';
|
||||||
|
import * as billingApi from '../../api/billing';
|
||||||
|
|
||||||
|
// Mock the billing API
|
||||||
|
vi.mock('../../api/billing', () => ({
|
||||||
|
getEntitlements: vi.fn(),
|
||||||
|
getCurrentSubscription: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createWrapper = () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useEntitlements', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches and returns entitlements', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
max_users: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initially loading
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.entitlements).toEqual(mockEntitlements);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasFeature returns true for enabled boolean features', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
can_use_mobile_app: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasFeature('can_use_sms_reminders')).toBe(true);
|
||||||
|
expect(result.current.hasFeature('can_use_mobile_app')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasFeature returns false for non-existent features', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.hasFeature('nonexistent_feature')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLimit returns integer value for limit features', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
max_users: 10,
|
||||||
|
max_resources: 25,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.getLimit('max_users')).toBe(10);
|
||||||
|
expect(result.current.getLimit('max_resources')).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLimit returns null for non-existent limits', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
max_users: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.getLimit('nonexistent_limit')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getLimit returns null for boolean features', async () => {
|
||||||
|
const mockEntitlements = {
|
||||||
|
can_use_sms_reminders: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce(mockEntitlements);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Boolean features should not be returned as limits
|
||||||
|
expect(result.current.getLimit('can_use_sms_reminders')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns loading state initially', () => {
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockImplementation(
|
||||||
|
() => new Promise(() => {}) // Never resolves
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isLoading).toBe(true);
|
||||||
|
expect(result.current.entitlements).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty entitlements when API returns empty', async () => {
|
||||||
|
// When getEntitlements encounters an error, it returns {} (see billing.ts)
|
||||||
|
// So we test that behavior by having the mock return {}
|
||||||
|
vi.mocked(billingApi.getEntitlements).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useEntitlements(), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.entitlements).toEqual({});
|
||||||
|
expect(result.current.hasFeature('any_feature')).toBe(false);
|
||||||
|
expect(result.current.getLimit('any_limit')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
132
frontend/src/hooks/useEntitlements.ts
Normal file
132
frontend/src/hooks/useEntitlements.ts
Normal 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];
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
3
smoothschedule/smoothschedule/commerce/billing/admin.py
Normal file
3
smoothschedule/smoothschedule/commerce/billing/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register billing models here
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
29
smoothschedule/smoothschedule/commerce/billing/api/urls.py
Normal file
29
smoothschedule/smoothschedule/commerce/billing/api/urls.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
176
smoothschedule/smoothschedule/commerce/billing/api/views.py
Normal file
176
smoothschedule/smoothschedule/commerce/billing/api/views.py
Normal 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)
|
||||||
8
smoothschedule/smoothschedule/commerce/billing/apps.py
Normal file
8
smoothschedule/smoothschedule/commerce/billing/apps.py
Normal 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"
|
||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
476
smoothschedule/smoothschedule/commerce/billing/models.py
Normal file
476
smoothschedule/smoothschedule/commerce/billing/models.py
Normal 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)"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
383
smoothschedule/smoothschedule/commerce/billing/tests/test_api.py
Normal file
383
smoothschedule/smoothschedule/commerce/billing/tests/test_api.py
Normal 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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user