- Add is_stackable field to AddOnProduct model for add-ons that can be purchased multiple times - Add quantity field to SubscriptionAddOn for tracking purchase count - Update EntitlementService to ADD integer add-on values to base plan (instead of max) and multiply by quantity for stackable add-ons - Add feature selection to AddOnEditorModal using FeaturePicker component - Add AddOnFeatureSerializer for nested feature CRUD on add-ons - Fix Create Add-on button styling to use solid blue (was muted outline) - Widen billing sidebar from 320px to 384px to prevent text wrapping 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
5.7 KiB
TypeScript
215 lines
5.7 KiB
TypeScript
/**
|
|
* 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,
|
|
price_one_time_cents: 0,
|
|
is_stackable: false,
|
|
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();
|
|
});
|
|
});
|
|
});
|