Public: anyone can read this; the crew can change it.
`checkout.session.completed` binds the paid checkout to its forge, customer and subscription #11289
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11246-checkout-session-completed-binds-the-pai → main 4 commits fork 2 threads · 2 unresolved
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); | |
| 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 @@ | ||
| 6 | 6 | * only then parsed. A bad or stale signature is 400 and counted per UTC day |
| 7 | 7 | * (`stripe_hook_refused`). A verified event's id is stored once; a repeated id |
| 8 | 8 | * 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`). | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | import type { App } from "../app.ts"; |
| 13 | import { bindCheckout } from "../billing/checkout_completed.ts"; | |
| 12 | 14 | import { openWebhookSecret } from "../billing/connection.ts"; |
| 13 | 15 | import { verifyStripeSignature } from "../billing/signature.ts"; |
| 14 | 16 | import { RateLimiter } from "../core/ratelimit.ts"; |
| @@ -98,6 +100,10 @@ export async function stripeHook(ctx: Ctx): Promise<Response> { | ||
| 98 | 100 | if (e === null || typeof e !== "object" || typeof e.id !== "string" || !EVENT_ID.test(e.id)) return answer(400); |
| 99 | 101 | const type = typeof e.type === "string" && EVENT_TYPE.test(e.type) ? e.type : "unknown"; |
| 100 | 102 | // 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 | })(); | |
| 102 | 108 | return answer(200); |
| 103 | 109 | } |
test/stripe-hook.test.ts +61
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | // POST /hooks/stripe: a signed event once, 400 on a bad or stale signature, 200 and nothing on a replay (#11244). |
| 2 | 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 3 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 3 | 4 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 5 | import { tmpdir } from "node:os"; |
| 5 | 6 | import { join } from "node:path"; |
| 6 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 8 | import { sealKey, sealWebhook } from "../src/billing/connection.ts"; |
| 9 | import { getStored } from "../src/billing/store.ts"; | |
| 8 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 11 | import { refusedCount, setStripeHookCaps } from "../src/http/stripe_hook.ts"; |
| 10 | 12 | |
| @@ -133,4 +135,63 @@ describe("POST /hooks/stripe", () => { | ||
| 133 | 135 | await post("{}", "t=1,v1=00"); // a new day row: prune |
| 134 | 136 | expect(old()).toBeNull(); |
| 135 | 137 | }); |
| 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 | }); | |
| 136 | 197 | }); |
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.