Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/studio/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
37 changes: 37 additions & 0 deletions apps/studio/test/billing.worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Loading