diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a174001..f8986f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "@eslint/js": "^9.39.1", "@playwright/test": "^1.48.0", "@tailwindcss/postcss": "^4.1.17", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -2071,7 +2072,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2160,8 +2160,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2598,7 +2597,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3300,8 +3298,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4972,7 +4969,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5415,7 +5411,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5431,7 +5426,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -5444,8 +5438,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", diff --git a/frontend/package.json b/frontend/package.json index cc458e6..170d68d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "@eslint/js": "^9.39.1", "@playwright/test": "^1.48.0", "@tailwindcss/postcss": "^4.1.17", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", diff --git a/frontend/src/api/__tests__/billing.test.ts b/frontend/src/api/__tests__/billing.test.ts new file mode 100644 index 0000000..9d3983d --- /dev/null +++ b/frontend/src/api/__tests__/billing.test.ts @@ -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(); + }); + }); +}); diff --git a/frontend/src/api/billing.ts b/frontend/src/api/billing.ts new file mode 100644 index 0000000..2659acd --- /dev/null +++ b/frontend/src/api/billing.ts @@ -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 => { + try { + const response = await apiClient.get('/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 => { + try { + const response = await apiClient.get('/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 => { + const response = await apiClient.get('/billing/plans/'); + return response.data; +}; + +/** + * Get available add-on products. + */ +export const getAddOns = async (): Promise => { + const response = await apiClient.get('/billing/addons/'); + return response.data; +}; + +/** + * Get invoices for the current business. + */ +export const getInvoices = async (): Promise => { + const response = await apiClient.get('/billing/invoices/'); + return response.data; +}; + +/** + * Get a single invoice by ID. + * Returns null if not found. + */ +export const getInvoice = async (invoiceId: number): Promise => { + try { + const response = await apiClient.get(`/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; + } +}; diff --git a/frontend/src/components/FeatureGate.tsx b/frontend/src/components/FeatureGate.tsx new file mode 100644 index 0000000..c3b5e8b --- /dev/null +++ b/frontend/src/components/FeatureGate.tsx @@ -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 + * + * + * + * + * // With fallback + * } + * > + * + * + * + * // Multiple features (all required) + * + * + * + * + * // Multiple features (any one) + * + * + * + * ``` + */ +export const FeatureGate: React.FC = ({ + 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 + * } + * > + * + * + * ``` + */ +export const LimitGate: React.FC = ({ + 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 = ({ + feature, + message, + upgradeUrl = '/settings/billing', +}) => { + const displayMessage = + message || (feature ? `Upgrade your plan to access ${feature}` : 'Upgrade your plan to access this feature'); + + return ( +
+
+ + + + {displayMessage} +
+ + View upgrade options → + +
+ ); +}; + +export default FeatureGate; diff --git a/frontend/src/components/__tests__/FeatureGate.test.tsx b/frontend/src/components/__tests__/FeatureGate.test.tsx new file mode 100644 index 0000000..b07a38e --- /dev/null +++ b/frontend/src/components/__tests__/FeatureGate.test.tsx @@ -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( + +
SMS Feature Content
+
+ ); + + 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( + +
SMS Feature Content
+
+ ); + + 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( + Upgrade to access SMS} + > +
SMS Feature Content
+
+ ); + + 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( + +
SMS Feature Content
+
+ ); + + 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( + Loading...} + > +
SMS Feature Content
+
+ ); + + 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( + +
Multi Feature Content
+
+ ); + + // 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( + +
Multi Feature Content
+
+ ); + + // 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( + +
Under Limit Content
+
+ ); + + 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( + +
Under Limit Content
+
+ ); + + 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( + +
Under Limit Content
+
+ ); + + 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( + Upgrade for more users} + > +
Under Limit Content
+
+ ); + + 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( + +
Unlimited Content
+
+ ); + + // When limit is null, treat as unlimited + expect(screen.getByText('Unlimited Content')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useEntitlements.test.tsx b/frontend/src/hooks/__tests__/useEntitlements.test.tsx new file mode 100644 index 0000000..8a43241 --- /dev/null +++ b/frontend/src/hooks/__tests__/useEntitlements.test.tsx @@ -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 }) => ( + {children} + ); +}; + +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(); + }); +}); diff --git a/frontend/src/hooks/useEntitlements.ts b/frontend/src/hooks/useEntitlements.ts new file mode 100644 index 0000000..4d76cbe --- /dev/null +++ b/frontend/src/hooks/useEntitlements.ts @@ -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({ + 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]; diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index 15bd3d0..c1656ff 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -50,6 +50,7 @@ SHARED_APPS = [ 'djstripe', # Stripe integration # Commerce Domain (shared for platform support) + 'smoothschedule.commerce.billing', # Billing, subscriptions, entitlements 'smoothschedule.commerce.tickets', # Ticket system - shared for platform support access # Communication Domain (shared) diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index 9561a2b..8422b33 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -97,6 +97,8 @@ urlpatterns += [ path("notifications/", include("smoothschedule.communication.notifications.urls")), # Messaging API (broadcast messages) path("messages/", include("smoothschedule.communication.messaging.urls")), + # Billing API + path("", include("smoothschedule.commerce.billing.api.urls", namespace="billing")), # Platform API path("platform/", include("smoothschedule.platform.admin.urls", namespace="platform")), # OAuth Email Integration API diff --git a/smoothschedule/smoothschedule/commerce/billing/__init__.py b/smoothschedule/smoothschedule/commerce/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/commerce/billing/admin.py b/smoothschedule/smoothschedule/commerce/billing/admin.py new file mode 100644 index 0000000..bd13c7d --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register billing models here diff --git a/smoothschedule/smoothschedule/commerce/billing/api/__init__.py b/smoothschedule/smoothschedule/commerce/billing/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/commerce/billing/api/serializers.py b/smoothschedule/smoothschedule/commerce/billing/api/serializers.py new file mode 100644 index 0000000..0fab279 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/api/serializers.py @@ -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", + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/api/urls.py b/smoothschedule/smoothschedule/commerce/billing/api/urls.py new file mode 100644 index 0000000..d3b8ec1 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/api/urls.py @@ -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//", + InvoiceDetailView.as_view(), + name="invoice-detail", + ), +] diff --git a/smoothschedule/smoothschedule/commerce/billing/api/views.py b/smoothschedule/smoothschedule/commerce/billing/api/views.py new file mode 100644 index 0000000..5faf259 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/api/views.py @@ -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) diff --git a/smoothschedule/smoothschedule/commerce/billing/apps.py b/smoothschedule/smoothschedule/commerce/billing/apps.py new file mode 100644 index 0000000..4a3d71d --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/apps.py @@ -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" diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0001_initial_billing_models.py b/smoothschedule/smoothschedule/commerce/billing/migrations/0001_initial_billing_models.py new file mode 100644 index 0000000..1d87ee8 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/migrations/0001_initial_billing_models.py @@ -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')}, + }, + ), + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0002_add_invoice_models.py b/smoothschedule/smoothschedule/commerce/billing/migrations/0002_add_invoice_models.py new file mode 100644 index 0000000..7846856 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/migrations/0002_add_invoice_models.py @@ -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'], + }, + ), + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py b/smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py new file mode 100644 index 0000000..3f15f2c --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/migrations/0003_seed_initial_plans.py @@ -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), + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/0004_migrate_tenants_to_subscriptions.py b/smoothschedule/smoothschedule/commerce/billing/migrations/0004_migrate_tenants_to_subscriptions.py new file mode 100644 index 0000000..f2871e6 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/migrations/0004_migrate_tenants_to_subscriptions.py @@ -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), + ] diff --git a/smoothschedule/smoothschedule/commerce/billing/migrations/__init__.py b/smoothschedule/smoothschedule/commerce/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/commerce/billing/models.py b/smoothschedule/smoothschedule/commerce/billing/models.py new file mode 100644 index 0000000..c405e02 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/models.py @@ -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)" diff --git a/smoothschedule/smoothschedule/commerce/billing/services/__init__.py b/smoothschedule/smoothschedule/commerce/billing/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/commerce/billing/services/entitlements.py b/smoothschedule/smoothschedule/commerce/billing/services/entitlements.py new file mode 100644 index 0000000..abb2e86 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/services/entitlements.py @@ -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 diff --git a/smoothschedule/smoothschedule/commerce/billing/services/invoicing.py b/smoothschedule/smoothschedule/commerce/billing/services/invoicing.py new file mode 100644 index 0000000..d6e8b73 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/services/invoicing.py @@ -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 diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/__init__.py b/smoothschedule/smoothschedule/commerce/billing/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_api.py b/smoothschedule/smoothschedule/commerce/billing/tests/test_api.py new file mode 100644 index 0000000..6738184 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/tests/test_api.py @@ -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) diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py b/smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py new file mode 100644 index 0000000..8369271 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/tests/test_entitlements.py @@ -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 diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py b/smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py new file mode 100644 index 0000000..9a876d8 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/tests/test_invoicing.py @@ -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 diff --git a/smoothschedule/smoothschedule/commerce/billing/tests/test_models.py b/smoothschedule/smoothschedule/commerce/billing/tests/test_models.py new file mode 100644 index 0000000..bd31cb9 --- /dev/null +++ b/smoothschedule/smoothschedule/commerce/billing/tests/test_models.py @@ -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") diff --git a/smoothschedule/smoothschedule/identity/core/models.py b/smoothschedule/smoothschedule/identity/core/models.py index 4df20f6..c71cd42 100644 --- a/smoothschedule/smoothschedule/identity/core/models.py +++ b/smoothschedule/smoothschedule/identity/core/models.py @@ -417,8 +417,10 @@ class Tenant(TenantMixin): """ Check if this tenant has a specific feature permission. - Checks both the boolean field on the Tenant model and the subscription plan's - permissions JSON field. + Resolution order (highest to lowest precedence): + 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: 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): 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: plan_permissions = self.subscription_plan.permissions or {} return bool(plan_permissions.get(permission_key, False))