Implements a complete billing system with: Backend (Django): - New billing app with models: Feature, Plan, PlanVersion, PlanFeature, Subscription, AddOnProduct, AddOnFeature, SubscriptionAddOn, EntitlementOverride, Invoice, InvoiceLine - EntitlementService with resolution order: overrides > add-ons > plan - Invoice generation service with immutable snapshots - DRF API endpoints for entitlements, subscription, plans, invoices - Data migrations to seed initial plans and convert existing tenants - Bridge to legacy Tenant.has_feature() with fallback support - 75 tests covering models, services, and API endpoints Frontend (React): - Billing API client (getEntitlements, getPlans, getInvoices, etc.) - useEntitlements hook with hasFeature() and getLimit() helpers - FeatureGate and LimitGate components for conditional rendering - 29 tests for API, hook, and components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
185 lines
4.2 KiB
TypeScript
185 lines
4.2 KiB
TypeScript
/**
|
|
* Billing API
|
|
*
|
|
* API client functions for the billing/subscription system.
|
|
*/
|
|
|
|
import apiClient from './client';
|
|
|
|
// ============================================================================
|
|
// Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Entitlements - a map of feature codes to their values.
|
|
* Boolean features indicate permission (true/false).
|
|
* Integer features indicate limits.
|
|
*/
|
|
export interface Entitlements {
|
|
[key: string]: boolean | number | null;
|
|
}
|
|
|
|
/**
|
|
* Plan information (nested in PlanVersion)
|
|
*/
|
|
export interface Plan {
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
}
|
|
|
|
/**
|
|
* Plan version with pricing and features
|
|
*/
|
|
export interface PlanVersion {
|
|
id: number;
|
|
name: string;
|
|
is_legacy: boolean;
|
|
is_public?: boolean;
|
|
plan: Plan;
|
|
price_monthly_cents: number;
|
|
price_yearly_cents: number;
|
|
features?: PlanFeature[];
|
|
}
|
|
|
|
/**
|
|
* Feature attached to a plan version
|
|
*/
|
|
export interface PlanFeature {
|
|
feature_code: string;
|
|
feature_name: string;
|
|
feature_type: 'boolean' | 'integer';
|
|
bool_value?: boolean;
|
|
int_value?: number;
|
|
}
|
|
|
|
/**
|
|
* Current subscription
|
|
*/
|
|
export interface Subscription {
|
|
id: number;
|
|
status: 'active' | 'canceled' | 'past_due' | 'trialing';
|
|
plan_version: PlanVersion;
|
|
current_period_start: string;
|
|
current_period_end: string;
|
|
canceled_at?: string;
|
|
stripe_subscription_id?: string;
|
|
}
|
|
|
|
/**
|
|
* Add-on product
|
|
*/
|
|
export interface AddOnProduct {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
price_monthly_cents: number;
|
|
is_active: boolean;
|
|
}
|
|
|
|
/**
|
|
* Invoice line item
|
|
*/
|
|
export interface InvoiceLine {
|
|
id: number;
|
|
line_type: 'plan' | 'addon' | 'adjustment' | 'credit';
|
|
description: string;
|
|
quantity: number;
|
|
unit_amount: number;
|
|
total_amount: number;
|
|
}
|
|
|
|
/**
|
|
* Invoice
|
|
*/
|
|
export interface Invoice {
|
|
id: number;
|
|
status: 'draft' | 'pending' | 'paid' | 'void' | 'uncollectible';
|
|
period_start: string;
|
|
period_end: string;
|
|
subtotal_amount: number;
|
|
total_amount: number;
|
|
plan_name_at_billing: string;
|
|
plan_code_at_billing?: string;
|
|
created_at: string;
|
|
paid_at?: string;
|
|
lines?: InvoiceLine[];
|
|
}
|
|
|
|
// ============================================================================
|
|
// API Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get effective entitlements for the current business.
|
|
* Returns a map of feature codes to their values.
|
|
*/
|
|
export const getEntitlements = async (): Promise<Entitlements> => {
|
|
try {
|
|
const response = await apiClient.get<Entitlements>('/me/entitlements/');
|
|
return response.data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch entitlements:', error);
|
|
return {};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get the current subscription for the business.
|
|
* Returns null if no subscription exists.
|
|
*/
|
|
export const getCurrentSubscription = async (): Promise<Subscription | null> => {
|
|
try {
|
|
const response = await apiClient.get<Subscription>('/me/subscription/');
|
|
return response.data;
|
|
} catch (error: any) {
|
|
if (error?.response?.status === 404) {
|
|
return null;
|
|
}
|
|
console.error('Failed to fetch subscription:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get available plans (public, non-legacy plans).
|
|
*/
|
|
export const getPlans = async (): Promise<PlanVersion[]> => {
|
|
const response = await apiClient.get<PlanVersion[]>('/billing/plans/');
|
|
return response.data;
|
|
};
|
|
|
|
/**
|
|
* Get available add-on products.
|
|
*/
|
|
export const getAddOns = async (): Promise<AddOnProduct[]> => {
|
|
const response = await apiClient.get<AddOnProduct[]>('/billing/addons/');
|
|
return response.data;
|
|
};
|
|
|
|
/**
|
|
* Get invoices for the current business.
|
|
*/
|
|
export const getInvoices = async (): Promise<Invoice[]> => {
|
|
const response = await apiClient.get<Invoice[]>('/billing/invoices/');
|
|
return response.data;
|
|
};
|
|
|
|
/**
|
|
* Get a single invoice by ID.
|
|
* Returns null if not found.
|
|
*/
|
|
export const getInvoice = async (invoiceId: number): Promise<Invoice | null> => {
|
|
try {
|
|
const response = await apiClient.get<Invoice>(`/billing/invoices/${invoiceId}/`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
if (error?.response?.status === 404) {
|
|
return null;
|
|
}
|
|
console.error('Failed to fetch invoice:', error);
|
|
throw error;
|
|
}
|
|
};
|