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-bootstrap.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-bootstrap.tsBlame205 lines · 1 contributor
5f7c71eClaude1/**
2 * Stripe bootstrap — idempotent setup of products, prices, and webhook.
3 *
4 * Run via `.github/workflows/stripe-bootstrap.yml` (manual dispatch). Reads
5 * STRIPE_SECRET_KEY + APP_BASE_URL from env. Creates the 4 plan tiers
6 * (free is a no-op; pro/team/enterprise each get a product + monthly price
7 * with a lookup_key matching `gluecron_${slug}_monthly`). Then creates/
8 * updates the webhook endpoint at `${APP_BASE_URL}/api/webhooks/stripe`.
9 *
10 * Idempotent: re-running is safe. Prices are matched by lookup_key, products
11 * by name, webhooks by URL.
12 *
13 * Outputs (printed to stdout, masked in GH Actions):
14 * - The webhook signing secret (whsec_...) — must be stored as a
15 * Fly secret STRIPE_WEBHOOK_SECRET.
16 *
17 * This script uses only fetch + URLSearchParams — no Stripe SDK dependency.
18 */
19
20const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
21const APP_BASE_URL = process.env.APP_BASE_URL || "https://gluecron.fly.dev";
22
23if (!STRIPE_SECRET_KEY) {
24 console.error("STRIPE_SECRET_KEY not set — aborting.");
25 process.exit(1);
26}
27
28const PLANS = [
29 { slug: "pro", name: "Gluecron Pro", priceCents: 900 },
30 { slug: "team", name: "Gluecron Team", priceCents: 2900 },
31 { slug: "enterprise", name: "Gluecron Enterprise", priceCents: 9900 },
32] as const;
33
34const WEBHOOK_URL = `${APP_BASE_URL.replace(/\/$/, "")}/api/webhooks/stripe`;
35const WEBHOOK_EVENTS = [
36 "checkout.session.completed",
37 "customer.subscription.created",
38 "customer.subscription.updated",
39 "customer.subscription.deleted",
40 "invoice.payment_failed",
41 "invoice.payment_succeeded",
42];
43
44type StripeResult<T> = { ok: true; data: T } | { ok: false; error: string };
45
46async function stripe<T = any>(
47 path: string,
48 body?: Record<string, string | string[]>,
49 method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
50): Promise<StripeResult<T>> {
51 const url = `https://api.stripe.com/v1${path}`;
52 const init: RequestInit = {
53 method,
54 headers: {
55 Authorization: `Bearer ${STRIPE_SECRET_KEY}`,
56 "Content-Type": "application/x-www-form-urlencoded",
57 },
58 };
59 if (body) {
60 const params = new URLSearchParams();
61 for (const [k, v] of Object.entries(body)) {
62 if (Array.isArray(v)) {
63 for (const item of v) params.append(`${k}[]`, item);
64 } else {
65 params.append(k, v);
66 }
67 }
68 init.body = params.toString();
69 }
70 try {
71 const res = await fetch(url, init);
72 const json = await res.json();
73 if (!res.ok) {
74 return { ok: false, error: `${res.status} ${json.error?.message || JSON.stringify(json)}` };
75 }
76 return { ok: true, data: json as T };
77 } catch (err) {
78 return { ok: false, error: err instanceof Error ? err.message : String(err) };
79 }
80}
81
82async function findProductByName(name: string): Promise<string | null> {
83 const res = await stripe<{ data: Array<{ id: string; name: string; active: boolean }> }>(
84 `/products?limit=100&active=true`
85 );
86 if (!res.ok) return null;
87 const match = res.data.data.find((p) => p.name === name);
88 return match?.id ?? null;
89}
90
91async function findPriceByLookupKey(lookupKey: string): Promise<string | null> {
92 const res = await stripe<{ data: Array<{ id: string; lookup_key: string }> }>(
93 `/prices?limit=100&lookup_keys[]=${encodeURIComponent(lookupKey)}`
94 );
95 if (!res.ok) return null;
96 return res.data.data[0]?.id ?? null;
97}
98
99async function findWebhookByUrl(url: string): Promise<string | null> {
100 const res = await stripe<{ data: Array<{ id: string; url: string }> }>(
101 `/webhook_endpoints?limit=100`
102 );
103 if (!res.ok) return null;
104 return res.data.data.find((w) => w.url === url)?.id ?? null;
105}
106
107async function ensureProduct(plan: (typeof PLANS)[number]): Promise<string> {
108 const existing = await findProductByName(plan.name);
109 if (existing) {
110 console.log(` ✓ Product already exists: ${plan.name} (${existing})`);
111 return existing;
112 }
113 const res = await stripe<{ id: string }>(`/products`, {
114 name: plan.name,
115 "metadata[gluecron_plan_slug]": plan.slug,
116 });
117 if (!res.ok) throw new Error(`Failed to create product ${plan.name}: ${res.error}`);
118 console.log(` + Created product: ${plan.name} (${res.data.id})`);
119 return res.data.id;
120}
121
122async function ensurePrice(
123 productId: string,
124 plan: (typeof PLANS)[number]
125): Promise<string> {
126 const lookupKey = `gluecron_${plan.slug}_monthly`;
127 const existing = await findPriceByLookupKey(lookupKey);
128 if (existing) {
129 console.log(` ✓ Price already exists: ${lookupKey} (${existing})`);
130 return existing;
131 }
132 const res = await stripe<{ id: string }>(`/prices`, {
133 product: productId,
134 unit_amount: String(plan.priceCents),
135 currency: "usd",
136 "recurring[interval]": "month",
137 lookup_key: lookupKey,
138 "metadata[gluecron_plan_slug]": plan.slug,
139 });
140 if (!res.ok) throw new Error(`Failed to create price ${lookupKey}: ${res.error}`);
141 console.log(` + Created price: ${lookupKey} (${res.data.id})`);
142 return res.data.id;
143}
144
145async function ensureWebhook(): Promise<{ id: string; secret: string | null }> {
146 const existing = await findWebhookByUrl(WEBHOOK_URL);
147 if (existing) {
148 console.log(` ✓ Webhook already exists for ${WEBHOOK_URL} (${existing})`);
149 console.log(` (signing secret is only shown at creation; not re-retrievable)`);
150 return { id: existing, secret: null };
151 }
152 const res = await stripe<{ id: string; secret: string }>(`/webhook_endpoints`, {
153 url: WEBHOOK_URL,
154 enabled_events: WEBHOOK_EVENTS,
155 description: "gluecron billing webhook (auto-created by stripe-bootstrap.ts)",
156 });
157 if (!res.ok) throw new Error(`Failed to create webhook: ${res.error}`);
158 console.log(` + Created webhook: ${WEBHOOK_URL} (${res.data.id})`);
159 return { id: res.data.id, secret: res.data.secret };
160}
161
162async function main() {
163 const mode = STRIPE_SECRET_KEY.startsWith("sk_live_") ? "LIVE" : "TEST";
164 console.log(`\nStripe bootstrap — ${mode} mode`);
165 console.log(`App base URL: ${APP_BASE_URL}`);
166 console.log(`Webhook URL: ${WEBHOOK_URL}\n`);
167
168 console.log("== Products + prices ==");
169 for (const plan of PLANS) {
170 const productId = await ensureProduct(plan);
171 await ensurePrice(productId, plan);
172 }
173
174 console.log("\n== Webhook endpoint ==");
175 const webhook = await ensureWebhook();
176
177 console.log("\n== Summary ==");
178 console.log(`Mode: ${mode}`);
179 console.log(`Products: ${PLANS.length} (pro/team/enterprise)`);
180 console.log(`Webhook: ${webhook.id}`);
181
182 if (webhook.secret) {
183 console.log("\n⚠️ WEBHOOK SIGNING SECRET (save this — only shown once):");
184 console.log(`STRIPE_WEBHOOK_SECRET=${webhook.secret}`);
185 // GitHub Actions masking so it doesn't land in logs
186 console.log(`::add-mask::${webhook.secret}`);
187 // Emit for workflow step to capture
188 if (process.env.GITHUB_OUTPUT) {
189 const fs = await import("fs");
190 fs.appendFileSync(
191 process.env.GITHUB_OUTPUT,
192 `webhook_secret=${webhook.secret}\n`
193 );
194 }
195 } else {
196 console.log("\n(Webhook already existed — signing secret not re-retrievable.)");
197 console.log("If you need to rotate it, delete the webhook in the Stripe dashboard");
198 console.log("and re-run this script.");
199 }
200}
201
202main().catch((err) => {
203 console.error("Bootstrap failed:", err instanceof Error ? err.message : err);
204 process.exit(1);
205});