learning_ai_common_plat/packages/billing-client/src/index.ts
saravanakumardb1 774244eaa2 feat(billing-client): add @bytelyst/billing-client shared package
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.
2026-04-13 10:28:29 -07:00

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');
},
};
}