/** * @bytelyst/billing-client — Browser/React Native-safe billing client * * Wraps platform-service /plans, /subscriptions, /payments, /usage * endpoints with typed methods. Any ByteLyst product can add billing * with minimal wiring: * * @example * ```ts * import { createBillingClient } from '@bytelyst/billing-client'; * * const billing = createBillingClient({ * baseUrl: 'http://localhost:4003/api', * productId: 'notelett', * getAccessToken: () => localStorage.getItem('notelett_access_token'), * }); * * const plans = await billing.listPlans(); * const sub = await billing.getSubscription(); * await billing.changePlan('pro'); * ``` */ // ── Types ──────────────────────────────────────────────────── export type PlanTier = 'free' | 'pro' | 'enterprise'; export type SubscriptionStatus = 'active' | 'cancelled' | 'past_due' | 'trialing'; export type PaymentStatus = 'succeeded' | 'pending' | 'failed' | 'refunded'; export interface PlanConfig { id: string; productId: string; name: string; displayName: string; price: number; tokens: number; words: number; dictations: number; features: string[]; stripePriceId?: string; active: boolean; createdAt: string; updatedAt: string; } export interface Subscription { id: string; productId: string; userId: string; plan: PlanTier; status: SubscriptionStatus; currentPeriodStart: string; currentPeriodEnd: string; cancelAtPeriodEnd: boolean; monthlyPrice: number; tokensIncluded: number; tokensUsed: number; stripeCustomerId?: string; stripeSubscriptionId?: string; createdAt: string; updatedAt: string; } export interface Payment { id: string; productId: string; userId: string; amount: number; currency: string; status: PaymentStatus; description: string; method: string; invoiceUrl?: string; createdAt: string; } export interface UsageSummary { tokensUsed: number; tokensIncluded: number; tokensRemaining: number; percentUsed: number; currentPeriodStart: string; currentPeriodEnd: string; } // ── Config ─────────────────────────────────────────────────── export interface BillingClientConfig { /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ baseUrl: string; /** Product identifier. */ productId: string; /** Returns current access token, or null if not authenticated. */ getAccessToken: () => string | null; /** Request timeout in ms (default: 15000). */ timeoutMs?: number; } // ── Client Interface ───────────────────────────────────────── export interface BillingClient { /** List available plans for the product. */ listPlans(): Promise; /** Get a specific plan by name. */ getPlan(planName: string): Promise; /** Get current user's subscription. Returns null if no subscription. */ getSubscription(): Promise; /** Change to a different plan (creates or updates subscription). */ changePlan(plan: PlanTier): Promise; /** Cancel subscription at period end. */ cancelSubscription(): Promise; /** Resume a cancelled subscription (undo cancel-at-period-end). */ resumeSubscription(): Promise; /** List payment history. */ listPayments(): Promise; /** Get usage summary for current billing period. */ getUsage(): Promise; } // ── Errors ─────────────────────────────────────────────────── export class BillingApiError extends Error { constructor( public readonly status: number, public readonly body: unknown, message?: string ) { super(message ?? `Billing API error ${status}`); this.name = 'BillingApiError'; } } // ── Factory ────────────────────────────────────────────────── export function createBillingClient(config: BillingClientConfig): BillingClient { const { baseUrl, productId, getAccessToken, timeoutMs = 15_000 } = config; async function request(method: string, path: string, body?: unknown): Promise { const headers: Record = { 'Content-Type': 'application/json', 'x-product-id': productId, }; const token = getAccessToken(); if (token) headers['Authorization'] = `Bearer ${token}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await globalThis.fetch(`${baseUrl}${path}`, { method, headers, body: body != null ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (res.status === 204) return undefined as T; if (res.status === 404) return null as T; const json = await res.json().catch(() => ({})); if (!res.ok) { throw new BillingApiError( res.status, json, (json as Record).message ?? `HTTP ${res.status}` ); } return json as T; } finally { clearTimeout(timer); } } return { async listPlans() { const result = await request<{ plans: PlanConfig[] }>('GET', '/plans'); return result.plans; }, async getPlan(planName: string) { return request('GET', `/plans/${encodeURIComponent(planName)}`); }, async getSubscription() { // Platform-service expects userId in path; the userId comes from the JWT. // We use a convenience endpoint that reads userId from the token. return request('GET', '/subscriptions/me'); }, async changePlan(plan: PlanTier) { return request('POST', '/subscriptions/me/change-plan', { plan }); }, async cancelSubscription() { return request('POST', '/subscriptions/me/cancel'); }, async resumeSubscription() { return request('POST', '/subscriptions/me/resume'); }, async listPayments() { const result = await request<{ payments: Payment[] }>('GET', '/payments/me'); return result.payments; }, async getUsage() { return request('GET', '/subscriptions/me/usage'); }, }; }