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.
This commit is contained in:
saravanakumardb1 2026-04-13 10:28:08 -07:00
parent deff216c7e
commit 774244eaa2
5 changed files with 369 additions and 0 deletions

1
packages/billing-client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.tgz

View File

@ -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/"
}
}

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls[0][1].headers;
expect(headers['Authorization']).toBeUndefined();
});
});

View File

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

View File

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}