Public: anyone can read this; the crew can change it.

`checkout.session.completed` binds the paid checkout to its forge, customer and subscription #11289

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/11246-checkout-session-completed-binds-the-pai → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/11246-checkout-session-completed-binds-the-pai was merged into main as 0be0797.

Review

Sign in to review this change.
4 files changed +132 −1
docs/features/stripe-checkout-binding.md added +18
@@ -0,0 +1,18 @@
1 # Stripe checkout binding
2
3 `POST /hooks/stripe` (signed, see `src/http/stripe_hook.ts`) acts on
4 `checkout.session.completed` through `src/billing/checkout_completed.ts`.
5
6 The session must set `metadata.forge` to the name of a forge this forge made,
7 and may set `metadata.plan` to a plan size. It binds only when `mode` is
8 `subscription` and `payment_status` is `paid` or `no_payment_required`, and
9 `customer` and `subscription` are Stripe ids. Then the forge's entitlement is
10 `active`, provider `stripe`, source `stripe`, with both ids stored, and the
11 change is kept in `entitlement_change`.
12
13 Anything else (unknown forge, unpaid, another mode, malformed fields) is
14 recorded in `stripe_event` and changes nothing. The event id and the binding
15 are one transaction: if binding fails the answer is an error and Stripe's retry binds.
16
17 To try it: seal a signing secret, POST a signed event, and read the forge's
18 plan in the admin billing view.
src/billing/checkout_completed.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * `checkout.session.completed` (#11246): a paid subscription checkout is bound to the forge
3 * named in the session's metadata (`metadata.forge`, and `metadata.plan` when
4 * it names a plan). The forge's entitlement records Stripe's customer and
5 * subscription and becomes active, source `stripe`. A session whose forge is
6 * unknown, that is unpaid or not a subscription, or whose fields are not what Stripe sends, changes nothing: the
7 * event is already kept by the hook, and that is the record.
8 */
9
10 import type { App } from "../app.ts";
11 import { listChildrenNow } from "../actions/spawn.ts";
12 import { parseSize } from "../spawn/sizes.ts";
13 import { entitlementOf, putEntitlement } from "./store.ts";
14
15 const FORGE = /^[a-z0-9][a-z0-9-]{0,62}$/;
16 const STRIPE_ID = /^[A-Za-z0-9_]{1,100}$/;
17
18 const text = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
19
20 /** Bind the session in `data.object` to its forge. True when a forge was updated. */
21 export function bindCheckout(app: App, event: unknown): boolean {
22 const session = (event as { data?: { object?: unknown } } | null)?.data?.object as Record<string, unknown> | null | undefined;
23 if (session === null || typeof session !== "object") return false;
24 // Only a paid subscription checkout binds: an async method fires this event unpaid, and other modes buy no plan.
25 if (session.mode !== "subscription") return false;
26 if (session.payment_status !== "paid" && session.payment_status !== "no_payment_required") return false;
27 const meta = session.metadata as Record<string, unknown> | null | undefined;
28 const forge = text(meta?.forge);
29 if (forge === undefined || !FORGE.test(forge)) return false;
30 const child = listChildrenNow(app).find((c) => c.name === forge);
31 if (child === undefined) return false;
32 const customer = text(session.customer);
outdated

Add a guard here: session.payment_status must be paid/no_payment_required and mode subscription, else return false. Without it an unpaid session grants an active paid plan.

33 const subscription = text(session.subscription);
34 if (customer === undefined || !STRIPE_ID.test(customer)) return false;
35 if (subscription === undefined || !STRIPE_ID.test(subscription)) return false;
outdated

This write is what an unpaid session would wrongly activate; gate it on the paid check above.

36 const named = parseSize(meta?.plan);
37 const plan = named.ok ? named.value : entitlementOf(app.db, forge, child.size).plan;
38 putEntitlement(
39 app.db,
40 { forge, plan, status: "active", provider: "stripe", customerId: customer, subscriptionId: subscription, source: "stripe" },
41 child.size,
42 "",
43 );
44 app.bus.append({ kind: "billing.plan", payload: { forge, to: plan, source: "stripe" } });
45 return true;
46 }
src/http/stripe_hook.ts +7 −1
@@ -6,9 +6,11 @@
66 * only then parsed. A bad or stale signature is 400 and counted per UTC day
77 * (`stripe_hook_refused`). A verified event's id is stored once; a repeated id
88 * is 200 and does nothing, and so is an event type nobody acts on yet.
9 * `checkout.session.completed` binds its forge (`billing/checkout_completed.ts`).
910 */
1011
1112 import type { App } from "../app.ts";
13 import { bindCheckout } from "../billing/checkout_completed.ts";
1214 import { openWebhookSecret } from "../billing/connection.ts";
1315 import { verifyStripeSignature } from "../billing/signature.ts";
1416 import { RateLimiter } from "../core/ratelimit.ts";
@@ -98,6 +100,10 @@ export async function stripeHook(ctx: Ctx): Promise<Response> {
98100 if (e === null || typeof e !== "object" || typeof e.id !== "string" || !EVENT_ID.test(e.id)) return answer(400);
99101 const type = typeof e.type === "string" && EVENT_TYPE.test(e.type) ? e.type : "unknown";
100102 // A repeated id changes nothing: the primary key refuses it.
101 app.db.query("INSERT OR IGNORE INTO stripe_event (id, type, at) VALUES (?1, ?2, ?3)").run(e.id, type, now.toISOString());
103 // One transaction: if binding throws, the id is not kept, so Stripe's retry is not taken for a replay.
104 app.db.transaction(() => {
105 const stored = app.db.query("INSERT OR IGNORE INTO stripe_event (id, type, at) VALUES (?1, ?2, ?3)").run(e.id as string, type, now.toISOString());
106 if (stored.changes === 1 && type === "checkout.session.completed") bindCheckout(app, event);
107 })();
102108 return answer(200);
103109 }
test/stripe-hook.test.ts +61
@@ -1,10 +1,12 @@
11 // POST /hooks/stripe: a signed event once, 400 on a bad or stale signature, 200 and nothing on a replay (#11244).
22 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdirSync, writeFileSync } from "node:fs";
34 import { mkdtemp, rm } from "node:fs/promises";
45 import { tmpdir } from "node:os";
56 import { join } from "node:path";
67 import { createApp, type App } from "../src/app.ts";
78 import { sealKey, sealWebhook } from "../src/billing/connection.ts";
9 import { getStored } from "../src/billing/store.ts";
810 import { serve, type Servers } from "../src/http/server.ts";
911 import { refusedCount, setStripeHookCaps } from "../src/http/stripe_hook.ts";
1012
@@ -133,4 +135,63 @@ describe("POST /hooks/stripe", () => {
133135 await post("{}", "t=1,v1=00"); // a new day row: prune
134136 expect(old()).toBeNull();
135137 });
138
139 describe("checkout.session.completed", () => {
140 const session = (id: string, forge: unknown, extra: Record<string, unknown> = {}) =>
141 JSON.stringify({
142 id,
143 type: "checkout.session.completed",
144 data: { object: { mode: "subscription", payment_status: "paid", customer: "cus_123", subscription: "sub_456", metadata: { forge }, ...extra } },
145 });
146 const changeCount = (forge: string) => (app.db.query("SELECT COUNT(*) AS n FROM entitlement_change WHERE forge = ?1").get(forge) as { n: number }).n;
147 beforeAll(() => {
148 mkdirSync(join(dir, "spawn"), { recursive: true });
149 writeFileSync(join(dir, "spawn", "kid.json"), JSON.stringify({ name: "kid", bodyId: "cell-k", done: true, placement: "hosted", size: "small", apps: [], createdAt: new Date().toISOString() }));
150 });
151
152 test("binds customer and subscription to the named forge and marks it paid", async () => {
153 const body = session("evt_co1", "kid", { metadata: { forge: "kid", plan: "medium" } });
154 expect((await post(body, await sign(body))).status).toBe(200);
155 expect(getStored(app.db, "kid")).toMatchObject({
156 status: "active", plan: "medium", provider: "stripe", customerId: "cus_123", subscriptionId: "sub_456", source: "stripe",
157 });
158 });
159
160 test("a session for an unknown forge is recorded and changes nothing", async () => {
161 const body = session("evt_co2", "ghost");
162 expect((await post(body, await sign(body))).status).toBe(200);
163 expect(events()).toContainEqual({ id: "evt_co2", type: "checkout.session.completed" });
164 expect(getStored(app.db, "ghost")).toBeUndefined();
165 expect(changeCount("ghost")).toBe(0);
166 });
167
168 test("an unpaid or non-subscription session changes nothing", async () => {
169 const before = changeCount("kid");
170 const bodies = [
171 session("evt_co6", "kid", { payment_status: "unpaid" }),
172 session("evt_co7", "kid", { mode: "payment" }),
173 session("evt_co8", "kid", { payment_status: undefined }),
174 ];
175 for (const body of bodies) expect((await post(body, await sign(body))).status).toBe(200);
176 expect(changeCount("kid")).toBe(before);
177 });
178
179 test("a binding that throws keeps no event id, so the retry binds", async () => {
180 app.db.run("CREATE TRIGGER boom BEFORE INSERT ON entitlement_change BEGIN SELECT RAISE(ABORT, 'boom'); END");
181 const body = session("evt_co9", "kid", { customer: "cus_retry" });
182 expect((await post(body, await sign(body))).status).toBeGreaterThanOrEqual(400); // the server turns a db refusal into an error answer
183 app.db.run("DROP TRIGGER boom");
184 expect(events().find((e) => (e as { id: string }).id === "evt_co9")).toBeUndefined();
185 expect((await post(body, await sign(body))).status).toBe(200);
186 expect(getStored(app.db, "kid")?.customerId).toBe("cus_retry");
187 });
188
189 test("a malformed session changes nothing", async () => {
190 const before = (app.db.query("SELECT COUNT(*) AS n FROM entitlement_change").get() as { n: number }).n;
191 for (const [i, body] of [session("evt_co3", 7), session("evt_co4", "kid", { customer: 5 }), session("evt_co5", "../kid")].entries()) {
192 expect((await post(body, await sign(body))).status, String(i)).toBe(200);
193 }
194 expect((app.db.query("SELECT COUNT(*) AS n FROM entitlement_change").get() as { n: number }).n).toBe(before);
195 });
196 });
136197 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs