Skip to content

Commit 660d00f

Browse files
Programcodeclaude
andcommitted
Give the webhook a fake Stripe in the full-stack gate
The webhook now reads subscription state from Stripe, so it honours STRIPE_API_URL like the account function, and the fake serves GET /v1/subscriptions/:id from state the test sets. The billing journey also checks that a late active update cannot revive a canceled plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 66ca201 commit 660d00f

4 files changed

Lines changed: 50 additions & 14 deletions

File tree

‎e2e/full-stack/billing.spec.ts‎

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,25 +38,27 @@ function sendWebhook(event: object, signature = true) {
3838
});
3939
}
4040

41-
function subscriptionEvent(type: string, subscriptionId: string, status: string) {
41+
function subscriptionObject(subscriptionId: string, status: string) {
4242
const now = Math.floor(Date.now() / 1000);
43+
return {
44+
id: subscriptionId,
45+
object: "subscription",
46+
status,
47+
cancel_at_period_end: false,
48+
current_period_start: now,
49+
current_period_end: now + 30 * 86_400,
50+
cancel_at: null,
51+
canceled_at: null,
52+
ended_at: null,
53+
};
54+
}
55+
56+
function subscriptionEvent(type: string, subscriptionId: string, status: string) {
4357
return {
4458
id: `evt_${randomUUID()}`,
4559
object: "event",
4660
type,
47-
data: {
48-
object: {
49-
id: subscriptionId,
50-
object: "subscription",
51-
status,
52-
cancel_at_period_end: false,
53-
current_period_start: now,
54-
current_period_end: now + 30 * 86_400,
55-
cancel_at: null,
56-
canceled_at: null,
57-
ended_at: null,
58-
},
59-
},
61+
data: { object: subscriptionObject(subscriptionId, status) },
6062
};
6163
}
6264

@@ -114,6 +116,8 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin
114116
expect(forged.status).toBe(400);
115117
expect(await tier(userId)).toBe("expired");
116118

119+
// The webhook applies Stripe's current state, which the fake Stripe holds.
120+
await fake.stripeSubscription(subscriptionObject(subscriptionId, "active"));
117121
const accepted = await sendWebhook(activated);
118122
expect(accepted.status).toBe(200);
119123
expect(await tier(userId)).toBe("pro");
@@ -124,9 +128,14 @@ test("signed Stripe webhooks grant and revoke Pro, and forged ones change nothin
124128
await expect(page.getByText("Pro", { exact: true })).toBeVisible();
125129
await expect(page.getByRole("button", { name: "Manage Subscription" })).toBeVisible();
126130

131+
await fake.stripeSubscription(subscriptionObject(subscriptionId, "canceled"));
127132
const canceled = await sendWebhook(subscriptionEvent("customer.subscription.deleted", subscriptionId, "canceled"));
128133
expect(canceled.status).toBe(200);
129134
expect(await tier(userId)).toBe("expired");
135+
// An older "active" update that Stripe delivers late must not revive the plan.
136+
const stale = await sendWebhook(subscriptionEvent("customer.subscription.updated", subscriptionId, "active"));
137+
expect(stale.status).toBe(200);
138+
expect(await tier(userId)).toBe("expired");
130139
const { data: row } = await admin.from("subscriptions").select("status").eq("stripe_subscription_id", subscriptionId).single();
131140
expect(row?.status).toBe("canceled");
132141

‎e2e/full-stack/fake-openrouter.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const chatModel = (requested) => (liveModel === "as-requested" ? requested : liv
1717
const maxUpstreamCalls = Number(process.env.MAX_UPSTREAM_CALLS ?? 200);
1818
if (upstream && !liveModel) throw new Error("LIVE_MODEL is required with UPSTREAM");
1919
const requests = [];
20+
// Subscription state the fake Stripe returns, set by tests through /__subscription.
21+
const subscriptions = new Map();
2022
let failures = [];
2123
let upstreamCalls = 0;
2224

@@ -275,6 +277,11 @@ createServer(async (req, res) => {
275277
failures = [];
276278
return json(res, 200, { ok: true });
277279
}
280+
if (url.pathname === "/__subscription" && req.method === "POST") {
281+
const subscription = JSON.parse(await readBody(req) || "{}");
282+
subscriptions.set(subscription.id, subscription);
283+
return json(res, 200, { ok: true });
284+
}
278285
if (url.pathname === "/__fail" && req.method === "POST") {
279286
const { feature, count = 1, status = 500 } = JSON.parse(await readBody(req) || "{}");
280287
failures.push({ feature, count, status });
@@ -290,6 +297,14 @@ createServer(async (req, res) => {
290297
requests.push({ feature: "jev", model: body.model, status: 200 });
291298
return json(res, 200, { model: body.model, answers: jevAnswers(body.questions ?? {}), usage: { input_tokens: 40, output_tokens: 8, cost: 0.00001 } });
292299
}
300+
const subscriptionPath = url.pathname.match(/^\/v1\/subscriptions\/([^/]+)$/);
301+
if (subscriptionPath && req.method === "GET") {
302+
// The webhook applies Stripe's current subscription state, not the event's.
303+
const subscription = subscriptions.get(decodeURIComponent(subscriptionPath[1]));
304+
if (!subscription) return json(res, 404, { error: { type: "invalid_request_error", message: "No such subscription" } });
305+
requests.push({ feature: "stripe-subscription", user: subscription.id });
306+
return json(res, 200, subscription);
307+
}
293308
if (url.pathname === "/v1/customers/search" && req.method === "GET") {
294309
// Account deletion searches Stripe (through STRIPE_API_URL) for the user's
295310
// customers. Test accounts have none.

‎e2e/full-stack/stack-core.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export const fake = {
3434
async fail(feature: string, status = 500, count = 1) {
3535
await fetch(`${fakeUrl}/__fail`, { method: "POST", body: JSON.stringify({ feature, status, count }) });
3636
},
37+
/** Sets what the fake Stripe returns for GET /v1/subscriptions/:id. */
38+
async stripeSubscription(subscription: { id: string; status: string }) {
39+
await fetch(`${fakeUrl}/__subscription`, { method: "POST", body: JSON.stringify(subscription) });
40+
},
3741
};
3842

3943
export type DemoUser = { code: string; email: string; userId: () => Promise<string> };

‎supabase/functions/stripe-webhook/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,17 @@ if (!STRIPE_WEBHOOK_SECRET) throw new Error("Missing required env: STRIPE_WEBHOO
2323
if (!SUPABASE_URL) throw new Error("Missing required env: SUPABASE_URL");
2424
if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing required env: SUPABASE_SERVICE_ROLE_KEY");
2525

26+
// Only the local full-stack gate sets this, to reach its fake Stripe.
27+
const stripeApiUrl = Deno.env.get("STRIPE_API_URL");
28+
const stripeEndpoint = stripeApiUrl ? new URL(stripeApiUrl) : null;
2629
const stripe = new Stripe(STRIPE_SECRET_KEY, {
2730
apiVersion: "2023-10-16",
2831
httpClient: Stripe.createFetchHttpClient(),
32+
...(stripeEndpoint && {
33+
host: stripeEndpoint.hostname,
34+
port: stripeEndpoint.port || (stripeEndpoint.protocol === "http:" ? "80" : "443"),
35+
protocol: stripeEndpoint.protocol.replace(":", "") as "http" | "https",
36+
}),
2937
});
3038

3139
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);

0 commit comments

Comments
 (0)