export interface SubscriptionDoc { id: string; userId: string; plan: string; status: "active" | "trialing" | "past_due" | "cancelled" | "none"; currentPeriodEnd: string; cancelAtPeriodEnd: boolean; features?: 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< PlanConfig[] | { plans?: PlanConfig[] } | null >("/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)); }, }; }