export interface SubscriptionDoc { id: string; userId: string; productId?: string; plan: string; status: 'active' | 'trialing' | 'past_due' | 'cancelled' | 'none'; currentPeriodStart?: string; currentPeriodEnd: string; cancelAtPeriodEnd: boolean; monthlyPrice?: number; tokensIncluded?: number; tokensUsed?: number; stripeCustomerId?: string; stripeSubscriptionId?: string; features?: string[]; createdAt?: string; updatedAt?: string; } export interface PlanConfig { id: string; name: string; displayName: string; price: number; features: string[]; } export interface SubscriptionClientOptions { baseUrl: string; productId: string; userId: string; getAccessToken: () => string; } export interface SubscriptionClient { getMySubscription(): Promise; getPlans(): Promise; cancelSubscription(): Promise; isPro(): boolean; isTrialing(): boolean; hasFeature(feature: string): boolean; daysRemaining(): number | null; } function trimTrailingSlash(url: string): string { return url.replace(/\/+$/, ''); } export function createSubscriptionClient(opts: SubscriptionClientOptions): SubscriptionClient { const base = trimTrailingSlash(opts.baseUrl); let cached: SubscriptionDoc | null | undefined; async function request(path: string, init?: RequestInit): Promise { const token = opts.getAccessToken(); const headers = new Headers(init?.headers); headers.set('Authorization', `Bearer ${token}`); headers.set('X-Product-Id', opts.productId); if (!headers.has('Content-Type') && init?.body !== undefined) { headers.set('Content-Type', 'application/json'); } const res = await fetch(`${base}${path}`, { ...init, headers }); if (res.status === 404) { return null as T; } if (!res.ok) { throw new Error(`Subscription API error: ${res.status} ${res.statusText}`); } if (res.status === 204) { return null as T; } const text = await res.text(); if (!text) { return null as T; } return JSON.parse(text) as T; } function subscriptionFromCache(): SubscriptionDoc | null { if (cached === undefined || cached === null) { return null; } return cached; } return { async getMySubscription(): Promise { const data = await request('/billing/subscriptions/me'); cached = data; return data; }, async getPlans(): Promise { const data = await request('/billing/plans'); if (data == null) { return []; } if (Array.isArray(data)) { return data; } if (data && typeof data === 'object' && 'plans' in data && Array.isArray(data.plans)) { return data.plans; } return []; }, async cancelSubscription(): Promise { const data = await request('/billing/subscriptions/cancel', { method: 'POST', }); if (data === null) { throw new Error('Cancel subscription returned no body'); } cached = data; return data; }, isPro(): boolean { const sub = subscriptionFromCache(); if (!sub) { return false; } const paid = sub.status === 'active' || sub.status === 'trialing'; const planLower = sub.plan.toLowerCase(); const notFree = planLower !== 'free' && planLower !== 'none'; return paid && notFree; }, isTrialing(): boolean { return subscriptionFromCache()?.status === 'trialing'; }, hasFeature(feature: string): boolean { const sub = subscriptionFromCache(); if (!sub?.features?.length) { return false; } return sub.features.includes(feature); }, daysRemaining(): number | null { const sub = subscriptionFromCache(); if (!sub?.currentPeriodEnd) { return null; } const end = new Date(sub.currentPeriodEnd).getTime(); if (Number.isNaN(end)) { return null; } const ms = end - Date.now(); if (ms <= 0) { return 0; } return Math.ceil(ms / (1000 * 60 * 60 * 24)); }, }; }