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
159 lines
4.0 KiB
TypeScript
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));
|
|
},
|
|
};
|
|
}
|