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:
parent
deff216c7e
commit
774244eaa2
1
packages/billing-client/.gitignore
vendored
Normal file
1
packages/billing-client/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.tgz
|
||||
27
packages/billing-client/package.json
Normal file
27
packages/billing-client/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
119
packages/billing-client/src/index.test.ts
Normal file
119
packages/billing-client/src/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
214
packages/billing-client/src/index.ts
Normal file
214
packages/billing-client/src/index.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
8
packages/billing-client/tsconfig.json
Normal file
8
packages/billing-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user