/** * Browser/React Native-safe subscription client for platform-service. * * Wraps platform-service /subscriptions/* + /plans/* endpoints. * Caches subscription and plans for offline reads. * No Node.js dependencies — uses globalThis.fetch. */ import type { PlanConfig, SubscriptionClient, SubscriptionClientConfig, SubscriptionDoc, } from './types.js'; function generateRequestId(): string { return typeof globalThis.crypto?.randomUUID === 'function' ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } export function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient { const { baseUrl, productId, userId, getAccessToken, storage } = config; const SUB_KEY = `${productId}-subscription`; const PLANS_KEY = `${productId}-plans`; let cachedSub: SubscriptionDoc | null = null; let cachedPlans: PlanConfig[] = []; // Restore from storage on creation if (storage) { try { const raw = storage.getItem(SUB_KEY); if (raw) cachedSub = JSON.parse(raw) as SubscriptionDoc; } catch { /* ignore */ } try { const raw = storage.getItem(PLANS_KEY); if (raw) cachedPlans = JSON.parse(raw) as PlanConfig[]; } catch { /* ignore */ } } function headers(): Record { const h: Record = { 'Content-Type': 'application/json', 'x-product-id': productId, 'x-request-id': generateRequestId(), }; const token = getAccessToken(); if (token) h['Authorization'] = `Bearer ${token}`; return h; } function persistSub(sub: SubscriptionDoc | null): void { cachedSub = sub; if (storage) { try { storage.setItem(SUB_KEY, JSON.stringify(sub)); } catch { /* ignore */ } } } function persistPlans(plans: PlanConfig[]): void { cachedPlans = plans; if (storage) { try { storage.setItem(PLANS_KEY, JSON.stringify(plans)); } catch { /* ignore */ } } } async function getMySubscription(): Promise { const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { headers: headers(), }); if (res.status === 404) { persistSub(null); return null; } if (!res.ok) throw new Error(`getMySubscription failed: ${res.status}`); const sub = (await res.json()) as SubscriptionDoc; persistSub(sub); return sub; } async function getPlans(): Promise { const res = await globalThis.fetch(`${baseUrl}/plans`, { headers: headers() }); if (!res.ok) throw new Error(`getPlans failed: ${res.status}`); const data = (await res.json()) as { plans: PlanConfig[] }; const plans = data.plans; persistPlans(plans); return plans; } async function startTrial(planName = 'pro'): Promise { const res = await globalThis.fetch(`${baseUrl}/subscriptions`, { method: 'POST', headers: headers(), body: JSON.stringify({ userId, productId, plan: planName, status: 'trialing' }), }); if (!res.ok) throw new Error(`startTrial failed: ${res.status}`); const sub = (await res.json()) as SubscriptionDoc; persistSub(sub); return sub; } async function cancelSubscription(): Promise { const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { method: 'PUT', headers: headers(), body: JSON.stringify({ cancelAtPeriodEnd: true }), }); if (!res.ok) throw new Error(`cancelSubscription failed: ${res.status}`); const sub = (await res.json()) as SubscriptionDoc; persistSub(sub); return sub; } async function updateSubscription(updates: Partial): Promise { const res = await globalThis.fetch(`${baseUrl}/subscriptions/${encodeURIComponent(userId)}`, { method: 'PUT', headers: headers(), body: JSON.stringify(updates), }); if (!res.ok) throw new Error(`updateSubscription failed: ${res.status}`); const sub = (await res.json()) as SubscriptionDoc; persistSub(sub); return sub; } function isPro(): boolean { if (!cachedSub) return false; return ( cachedSub.plan !== 'free' && (cachedSub.status === 'active' || cachedSub.status === 'trialing') ); } function isTrialing(): boolean { return cachedSub?.status === 'trialing' || false; } function hasFeature(feature: string): boolean { if (!cachedSub) return false; const plan = cachedPlans.find(p => p.name === cachedSub!.plan); if (!plan) return false; return plan.features.includes(feature); } function daysRemaining(): number | null { if (!cachedSub) return null; const end = new Date(cachedSub.currentPeriodEnd).getTime(); const now = Date.now(); const diff = end - now; if (diff <= 0) return 0; return Math.ceil(diff / (1000 * 60 * 60 * 24)); } function getCachedSubscription(): SubscriptionDoc | null { return cachedSub; } function getCachedPlans(): PlanConfig[] { return cachedPlans; } async function refresh(): Promise { await Promise.all([getMySubscription(), getPlans()]); } return { getMySubscription, getPlans, startTrial, cancelSubscription, updateSubscription, isPro, isTrialing, hasFeature, daysRemaining, getCachedSubscription, getCachedPlans, refresh, }; }