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