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; + } +});