Browser/React Native-safe typed client for platform-service billing endpoints: plans, subscriptions, payments, and usage. Follows the same factory pattern as broadcast-client and survey-client. Any ByteLyst product can add billing with 5 lines of wiring code. 12 tests covering all methods, auth headers, error handling, and 404 null-return for missing subscriptions.
215 lines
6.5 KiB
TypeScript
215 lines
6.5 KiB
TypeScript
/**
|
|
* @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<PlanConfig[]>;
|
|
/** Get a specific plan by name. */
|
|
getPlan(planName: string): Promise<PlanConfig>;
|
|
/** Get current user's subscription. Returns null if no subscription. */
|
|
getSubscription(): Promise<Subscription | null>;
|
|
/** Change to a different plan (creates or updates subscription). */
|
|
changePlan(plan: PlanTier): Promise<Subscription>;
|
|
/** Cancel subscription at period end. */
|
|
cancelSubscription(): Promise<Subscription>;
|
|
/** Resume a cancelled subscription (undo cancel-at-period-end). */
|
|
resumeSubscription(): Promise<Subscription>;
|
|
/** List payment history. */
|
|
listPayments(): Promise<Payment[]>;
|
|
/** Get usage summary for current billing period. */
|
|
getUsage(): Promise<UsageSummary>;
|
|
}
|
|
|
|
// ── 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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
'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<string, string>).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<PlanConfig>('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<Subscription | null>('GET', '/subscriptions/me');
|
|
},
|
|
|
|
async changePlan(plan: PlanTier) {
|
|
return request<Subscription>('POST', '/subscriptions/me/change-plan', { plan });
|
|
},
|
|
|
|
async cancelSubscription() {
|
|
return request<Subscription>('POST', '/subscriptions/me/cancel');
|
|
},
|
|
|
|
async resumeSubscription() {
|
|
return request<Subscription>('POST', '/subscriptions/me/resume');
|
|
},
|
|
|
|
async listPayments() {
|
|
const result = await request<{ payments: Payment[] }>('GET', '/payments/me');
|
|
return result.payments;
|
|
},
|
|
|
|
async getUsage() {
|
|
return request<UsageSummary>('GET', '/subscriptions/me/usage');
|
|
},
|
|
};
|
|
}
|