Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

stripe.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

stripe.tsBlame189 lines · 1 contributor
619109aClaude1/**
2 * Stripe REST helpers — fetch-based, no SDK dependency. Used by the billing
3 * upgrade flow and the webhook handler.
4 *
5 * All fns return discriminated `{ ok: true, ... }` / `{ ok: false, error }`
6 * results. Never throws — a Stripe outage must not break the primary
7 * request path.
8 *
9 * `STRIPE_SECRET_KEY` is read at call time (not module init) so tests can
10 * mutate env without reloading the module.
11 */
12
13const STRIPE_API = "https://api.stripe.com/v1";
14
15export type StripeFail = { ok: false; error: string };
16
17function getKey(): string | null {
18 const k = process.env.STRIPE_SECRET_KEY;
19 if (!k || k.length < 10) return null;
20 return k;
21}
22
23function encodeForm(body: Record<string, string | string[]>): string {
24 const params = new URLSearchParams();
25 for (const [k, v] of Object.entries(body)) {
26 if (Array.isArray(v)) for (const item of v) params.append(`${k}[]`, item);
27 else params.append(k, v);
28 }
29 return params.toString();
30}
31
32async function stripeRequest<T>(
33 path: string,
34 body?: Record<string, string | string[]>,
35 method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
36): Promise<{ ok: true; data: T } | StripeFail> {
37 const key = getKey();
38 if (!key) return { ok: false, error: "STRIPE_SECRET_KEY not configured" };
39 try {
40 const res = await fetch(`${STRIPE_API}${path}`, {
41 method,
42 headers: {
43 Authorization: `Bearer ${key}`,
44 "Content-Type": "application/x-www-form-urlencoded",
45 },
46 body: body ? encodeForm(body) : undefined,
47 });
48 const json = await res.json();
49 if (!res.ok) {
50 return {
51 ok: false,
52 error: `stripe ${method} ${path} ${res.status}: ${json.error?.message ?? "unknown"}`,
53 };
54 }
55 return { ok: true, data: json as T };
56 } catch (err) {
57 return {
58 ok: false,
59 error: `stripe ${method} ${path} network: ${err instanceof Error ? err.message : String(err)}`,
60 };
61 }
62}
63
64// ---------------------------------------------------------------------------
65// Customer
66// ---------------------------------------------------------------------------
67
68export async function findOrCreateCustomer(args: {
69 userId: string;
70 email: string;
71 existingCustomerId?: string | null;
72}): Promise<{ ok: true; customerId: string } | StripeFail> {
73 if (args.existingCustomerId) {
74 // Trust the caller's stored id; Stripe will 404 if it's stale, we fall
75 // back to create.
76 const check = await stripeRequest<{ id: string; deleted?: boolean }>(
77 `/customers/${encodeURIComponent(args.existingCustomerId)}`
78 );
79 if (check.ok && !check.data.deleted) {
80 return { ok: true, customerId: check.data.id };
81 }
82 }
83 const res = await stripeRequest<{ id: string }>(`/customers`, {
84 email: args.email,
85 "metadata[gluecron_user_id]": args.userId,
86 });
87 if (!res.ok) return res;
88 return { ok: true, customerId: res.data.id };
89}
90
91// ---------------------------------------------------------------------------
92// Checkout Session
93// ---------------------------------------------------------------------------
94
95export async function createCheckoutSession(args: {
96 customerId: string;
97 planSlug: "pro" | "team" | "enterprise";
98 successUrl: string;
99 cancelUrl: string;
100 userId: string;
101}): Promise<{ ok: true; url: string; sessionId: string } | StripeFail> {
102 const lookupKey = `gluecron_${args.planSlug}_monthly`;
103 // Resolve price by lookup_key — matches what stripe-bootstrap seeds.
104 const priceRes = await stripeRequest<{ data: Array<{ id: string }> }>(
105 `/prices?lookup_keys[]=${encodeURIComponent(lookupKey)}&limit=1`
106 );
107 if (!priceRes.ok) return priceRes;
108 const priceId = priceRes.data.data[0]?.id;
109 if (!priceId) {
110 return {
111 ok: false,
112 error: `no Stripe price found for lookup_key=${lookupKey} — run the Stripe Bootstrap workflow first`,
113 };
114 }
115 const res = await stripeRequest<{ id: string; url: string }>(
116 `/checkout/sessions`,
117 {
118 mode: "subscription",
119 customer: args.customerId,
120 "line_items[0][price]": priceId,
121 "line_items[0][quantity]": "1",
122 success_url: args.successUrl,
123 cancel_url: args.cancelUrl,
124 client_reference_id: args.userId,
125 "metadata[gluecron_user_id]": args.userId,
126 "metadata[gluecron_plan_slug]": args.planSlug,
127 "subscription_data[metadata][gluecron_user_id]": args.userId,
128 "subscription_data[metadata][gluecron_plan_slug]": args.planSlug,
129 }
130 );
131 if (!res.ok) return res;
132 return { ok: true, url: res.data.url, sessionId: res.data.id };
133}
134
135// ---------------------------------------------------------------------------
136// Customer Portal (for existing subscribers to manage their plan)
137// ---------------------------------------------------------------------------
138
139export async function createBillingPortalSession(args: {
140 customerId: string;
141 returnUrl: string;
142}): Promise<{ ok: true; url: string } | StripeFail> {
143 const res = await stripeRequest<{ url: string }>(`/billing_portal/sessions`, {
144 customer: args.customerId,
145 return_url: args.returnUrl,
146 });
147 if (!res.ok) return res;
148 return { ok: true, url: res.data.url };
149}
150
151// ---------------------------------------------------------------------------
152// Subscription fetch (used by webhook to enrich on events)
153// ---------------------------------------------------------------------------
154
155export type StripeSubscription = {
156 id: string;
157 status: string;
158 customer: string;
159 current_period_end: number;
160 items: { data: Array<{ price: { id: string; lookup_key?: string } }> };
161 metadata?: Record<string, string>;
162};
163
164export async function getSubscription(
165 subscriptionId: string
166): Promise<{ ok: true; subscription: StripeSubscription } | StripeFail> {
167 const res = await stripeRequest<StripeSubscription>(
168 `/subscriptions/${encodeURIComponent(subscriptionId)}`
169 );
170 if (!res.ok) return res;
171 return { ok: true, subscription: res.data };
172}
173
174/** Given a Stripe subscription, derive the gluecron plan slug. Prefers
175 * the lookup_key on the price item; falls back to subscription metadata. */
176export function planSlugFromSubscription(
177 sub: StripeSubscription
178): "pro" | "team" | "enterprise" | null {
179 const lk = sub.items?.data?.[0]?.price?.lookup_key;
180 if (lk?.startsWith("gluecron_") && lk.endsWith("_monthly")) {
181 const slug = lk.slice("gluecron_".length, -"_monthly".length);
182 if (slug === "pro" || slug === "team" || slug === "enterprise") return slug;
183 }
184 const metaSlug = sub.metadata?.gluecron_plan_slug;
185 if (metaSlug === "pro" || metaSlug === "team" || metaSlug === "enterprise") {
186 return metaSlug;
187 }
188 return null;
189}