From 774244eaa2290f734d35ff57e7386c55ac1ee38b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 13 Apr 2026 10:28:08 -0700 Subject: [PATCH] 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. --- packages/billing-client/.gitignore | 1 + packages/billing-client/package.json | 27 +++ packages/billing-client/src/index.test.ts | 119 ++++++++++++ packages/billing-client/src/index.ts | 214 ++++++++++++++++++++++ packages/billing-client/tsconfig.json | 8 + 5 files changed, 369 insertions(+) create mode 100644 packages/billing-client/.gitignore create mode 100644 packages/billing-client/package.json create mode 100644 packages/billing-client/src/index.test.ts create mode 100644 packages/billing-client/src/index.ts create mode 100644 packages/billing-client/tsconfig.json diff --git a/packages/billing-client/.gitignore b/packages/billing-client/.gitignore new file mode 100644 index 00000000..aa1ec1ea --- /dev/null +++ b/packages/billing-client/.gitignore @@ -0,0 +1 @@ +*.tgz diff --git a/packages/billing-client/package.json b/packages/billing-client/package.json new file mode 100644 index 00000000..ae7810f2 --- /dev/null +++ b/packages/billing-client/package.json @@ -0,0 +1,27 @@ +{ + "name": "@bytelyst/billing-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe billing and subscription client for platform-service — plans, subscriptions, payments, and usage", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run --pool forks" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "publishConfig": { + "registry": "https://gitea.bytelyst.com/api/packages/ByteLyst/npm/" + } +} diff --git a/packages/billing-client/src/index.test.ts b/packages/billing-client/src/index.test.ts new file mode 100644 index 00000000..8a7c05bd --- /dev/null +++ b/packages/billing-client/src/index.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createBillingClient, BillingApiError } from './index.js'; +import type { BillingClient } from './index.js'; + +function mockFetch(status: number, body: unknown) { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }); +} + +describe('createBillingClient', () => { + let client: BillingClient; + const config = { + baseUrl: 'http://localhost:4003/api', + productId: 'notelett', + getAccessToken: () => 'test-token', + }; + + beforeEach(() => { + client = createBillingClient(config); + }); + + it('listPlans — returns plans array', async () => { + const plans = [{ name: 'free', displayName: 'Free', price: 0 }]; + globalThis.fetch = mockFetch(200, { plans }); + const result = await client.listPlans(); + expect(result).toEqual(plans); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:4003/api/plans', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('getPlan — returns single plan', async () => { + const plan = { name: 'pro', displayName: 'Pro', price: 9.99 }; + globalThis.fetch = mockFetch(200, plan); + const result = await client.getPlan('pro'); + expect(result).toEqual(plan); + }); + + it('getSubscription — returns subscription', async () => { + const sub = { id: 'sub_1', plan: 'free', status: 'active' }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.getSubscription(); + expect(result).toEqual(sub); + }); + + it('getSubscription — returns null on 404', async () => { + globalThis.fetch = mockFetch(404, { message: 'Not found' }); + const result = await client.getSubscription(); + expect(result).toBeNull(); + }); + + it('changePlan — sends plan in body', async () => { + const sub = { id: 'sub_1', plan: 'pro', status: 'active' }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.changePlan('pro'); + expect(result.plan).toBe('pro'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:4003/api/subscriptions/me/change-plan', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ plan: 'pro' }), + }) + ); + }); + + it('cancelSubscription — POST to cancel endpoint', async () => { + const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: true }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.cancelSubscription(); + expect(result.cancelAtPeriodEnd).toBe(true); + }); + + it('resumeSubscription — POST to resume endpoint', async () => { + const sub = { id: 'sub_1', plan: 'pro', cancelAtPeriodEnd: false }; + globalThis.fetch = mockFetch(200, sub); + const result = await client.resumeSubscription(); + expect(result.cancelAtPeriodEnd).toBe(false); + }); + + it('listPayments — returns payments array', async () => { + const payments = [{ id: 'pay_1', amount: 999, status: 'succeeded' }]; + globalThis.fetch = mockFetch(200, { payments }); + const result = await client.listPayments(); + expect(result).toEqual(payments); + }); + + it('getUsage — returns usage summary', async () => { + const usage = { tokensUsed: 500, tokensIncluded: 10000, tokensRemaining: 9500, percentUsed: 5 }; + globalThis.fetch = mockFetch(200, usage); + const result = await client.getUsage(); + expect(result.tokensRemaining).toBe(9500); + }); + + it('sends auth header and product-id header', async () => { + globalThis.fetch = mockFetch(200, { plans: [] }); + await client.listPlans(); + const call = (globalThis.fetch as ReturnType).mock.calls[0]; + const headers = call[1].headers; + expect(headers['Authorization']).toBe('Bearer test-token'); + expect(headers['x-product-id']).toBe('notelett'); + }); + + it('throws BillingApiError on non-ok response', async () => { + globalThis.fetch = mockFetch(403, { message: 'Forbidden' }); + await expect(client.changePlan('enterprise')).rejects.toThrow(BillingApiError); + }); + + it('works without access token', async () => { + const noAuthClient = createBillingClient({ ...config, getAccessToken: () => null }); + globalThis.fetch = mockFetch(200, { plans: [] }); + await noAuthClient.listPlans(); + const headers = (globalThis.fetch as ReturnType).mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + }); +}); diff --git a/packages/billing-client/src/index.ts b/packages/billing-client/src/index.ts new file mode 100644 index 00000000..44db5d8e --- /dev/null +++ b/packages/billing-client/src/index.ts @@ -0,0 +1,214 @@ +/** + * @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'); + }, + }; +} diff --git a/packages/billing-client/tsconfig.json b/packages/billing-client/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/billing-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +}