From 69ccd1b07c0d1b1f51a9510c58525ac06f5a509f Mon Sep 17 00:00:00 2001 From: Neelank Sachan Date: Thu, 1 Oct 2026 06:34:58 +1000 Subject: [PATCH] Studio: say what Stripe refused, instead of "something went wrong" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A StripeError thrown outside the founding-coupon retry reached the generic handler, so an owner pressing Upgrade with a price the key can't see — a test price under a live key, say — got a blank 500 and no way to tell a mode mismatch from an outage. Stripe's own message now comes back: a refusal as 400 with its code and param, an outage as 502. Stripe's messages name the object that was asked for, not the key that asked, so nothing secret travels with them. Co-Authored-By: Claude Opus 5 --- apps/studio/src/worker/index.ts | 10 +++++++ apps/studio/test/billing.worker.test.ts | 37 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/studio/src/worker/index.ts b/apps/studio/src/worker/index.ts index e8f0650..6837455 100644 --- a/apps/studio/src/worker/index.ts +++ b/apps/studio/src/worker/index.ts @@ -52,6 +52,16 @@ class Fail extends Error { app.onError((e, c) => { if (e instanceof Fail) return c.json({ error: e.message }, e.status as 500); if (e instanceof PlanLimit) return c.json({ error: e.message, ...e.data }, 402); + // What Stripe refused, in Stripe's own words. "No such price" or "No such coupon" is the + // difference between a mode mismatch and an outage, and an opaque 500 hides both. Stripe's + // messages carry no secret: they name the object that was asked for, not the key that asked. + if (e instanceof StripeError) { + console.error(`Stripe ${e.status}${e.code ? ` ${e.code}` : ""}${e.param ? ` (${e.param})` : ""}: ${e.message}`); + return c.json( + { error: e.status >= 500 ? `Stripe didn't answer: ${e.message}` : `Stripe refused this: ${e.message}`, code: e.code, param: e.param }, + (e.status >= 500 ? 502 : 400) as 500, + ); + } console.error(e); return c.json({ error: "Something went wrong on our side" }, 500); }); diff --git a/apps/studio/test/billing.worker.test.ts b/apps/studio/test/billing.worker.test.ts index 0d97eb7..aa93727 100644 --- a/apps/studio/test/billing.worker.test.ts +++ b/apps/studio/test/billing.worker.test.ts @@ -379,3 +379,40 @@ test("Stripe's signature and form encoding", async () => { "mode=subscription&line_items[0][price]=p&line_items[0][quantity]=2&metadata[workspace_id]=w", ); }); + +test("a refusal from Stripe says what Stripe said, so a mode mismatch isn't an opaque 500", async () => { + const worker = await startWorker(STRIPE); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const real = globalThis.fetch; + // The shape of a live key asked for a test price: Stripe answers 400 resource_missing. + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!String(input).startsWith("https://api.stripe.com")) return real(input as RequestInfo, init); + return new Response(JSON.stringify({ error: { message: "No such price: 'price_pro_month'", code: "resource_missing", param: "line_items[0][price]" } }), { status: 400, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const r = await mira.call("POST", "/api/w/harbourline/billing/checkout", { plan: "pro", interval: "month" }); + assert.equal(r.status, 400, "a refusal, not a 500"); + assert.match(r.body.error, /No such price/); + assert.equal(r.body.code, "resource_missing"); + assert.equal(r.body.param, "line_items[0][price]"); + } finally { + globalThis.fetch = real; + } +}); + +test("Stripe being down is a 502, not a refusal the owner could act on", async () => { + const worker = await startWorker(STRIPE); + const mira = await worker.signUp("mira@harbourline.test", "Harbourline"); + const real = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!String(input).startsWith("https://api.stripe.com")) return real(input as RequestInfo, init); + return new Response(JSON.stringify({ error: { message: "Stripe is having a moment" } }), { status: 503, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + try { + const r = await mira.call("POST", "/api/w/harbourline/billing/checkout", { plan: "pro", interval: "month" }); + assert.equal(r.status, 502); + assert.match(r.body.error, /didn't answer/); + } finally { + globalThis.fetch = real; + } +});