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.
| 5f7c71e | 1 | /** |
| 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 | ||
| 20 | const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; | |
| 21 | const APP_BASE_URL = process.env.APP_BASE_URL || "https://gluecron.fly.dev"; | |
| 22 | ||
| 23 | if (!STRIPE_SECRET_KEY) { | |
| 24 | console.error("STRIPE_SECRET_KEY not set — aborting."); | |
| 25 | process.exit(1); | |
| 26 | } | |
| 27 | ||
| 28 | const 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 | ||
| 34 | const WEBHOOK_URL = `${APP_BASE_URL.replace(/\/$/, "")}/api/webhooks/stripe`; | |
| 35 | const 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 | ||
| 44 | type StripeResult<T> = { ok: true; data: T } | { ok: false; error: string }; | |
| 45 | ||
| 46 | async 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 | ||
| 82 | async 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 | ||
| 91 | async 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 | ||
| 99 | async 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 | ||
| 107 | async 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 | ||
| 122 | async 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 | ||
| 145 | async 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 | ||
| 162 | async 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 | ||
| 202 | main().catch((err) => { | |
| 203 | console.error("Bootstrap failed:", err instanceof Error ? err.message : err); | |
| 204 | process.exit(1); | |
| 205 | }); |