learning_ai_common_plat/packages/subscription-client/src/index.ts
Saravana Achu Mac e13d0cba6b fix(subscription-client): add missing X-Product-Id header to API requests
Aligns with referral-client, org-client, and marketplace-client which
all send this header. The billing API likely requires it for product
scoping.

Made-with: Cursor
2026-03-30 00:26:48 -07:00

159 lines
4.0 KiB
TypeScript

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<SubscriptionDoc | null>;
getPlans(): Promise<PlanConfig[]>;
cancelSubscription(): Promise<SubscriptionDoc>;
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<T>(
path: string,
init?: RequestInit,
): Promise<T> {
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<SubscriptionDoc | null> {
const data = await request<SubscriptionDoc | null>(
"/billing/subscriptions/me",
);
cached = data;
return data;
},
async getPlans(): Promise<PlanConfig[]> {
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<SubscriptionDoc> {
const data = await request<SubscriptionDoc>(
"/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));
},
};
}