learning_ai_common_plat/packages/subscription-client/src/client.ts
saravanakumardb1 be03efa111 feat(shared-packages): add 9 @bytelyst/* client packages with 100% API coverage
Packages added:
- @bytelyst/referral-client — referral API client + share helpers
- @bytelyst/subscription-client — subscription/plan API client + cache
- @bytelyst/celebrations — milestone triggers, confetti, positive messages
- @bytelyst/gentle-notifications — ND-friendly messaging, forbidden phrases
- @bytelyst/accessibility — VoiceOver/TalkBack label generators
- @bytelyst/quick-actions — progressive disclosure, smart defaults
- @bytelyst/time-references — familiar duration references
- @bytelyst/org-client — org/workspace/membership/license API client
- @bytelyst/marketplace-client — listing/review/install API client

All packages: pure TS, ESM, globalThis.fetch, no Node.js deps.
99 Vitest tests across 9 packages, 79/79 public methods covered.

Review fixes applied:
- time-references: fix module-level mutable state leak + add clearCustomReferences()
- accessibility: fix parameter reassignment in formatDurationForA11y/numberToWords
- subscription-client: fix flaky daysRemaining test (ms boundary race)
2026-03-19 13:10:09 -07:00

194 lines
5.4 KiB
TypeScript

/**
* 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<string, string> {
const h: Record<string, string> = {
'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<SubscriptionDoc | null> {
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<PlanConfig[]> {
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<SubscriptionDoc> {
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<SubscriptionDoc> {
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<SubscriptionDoc>): Promise<SubscriptionDoc> {
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<void> {
await Promise.all([getMySubscription(), getPlans()]);
}
return {
getMySubscription,
getPlans,
startTrial,
cancelSubscription,
updateSubscription,
isPro,
isTrialing,
hasFeature,
daysRemaining,
getCachedSubscription,
getCachedPlans,
refresh,
};
}