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

A Stripe subscription or invoice event writes what Stripe says now, so events out of order cannot undo a payment #11288

Merged opened this

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

Merged

mason/forge:mason/11245-a-stripe-subscription-or-invoice-event-w → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/11245-a-stripe-subscription-or-invoice-event-w was merged into main as 67e5cc2.

Review

Sign in to review this change.
6 files changed +294 −6
src/billing/connection.ts +1 −1
@@ -44,7 +44,7 @@ export function sealWebhook(db: Database, dataDir: string, secret: string, endpo
4444 }
4545
4646 /** The sealed key and the endpoint id, opened (audited as `secret.read`). */
47 export function openKey(db: Database, dataDir: string, bus: EventBus, actor: string): { key: string; endpointId: string } | undefined {
47 export function openKey(db: Database, dataDir: string, bus: EventBus, actor: string | undefined): { key: string; endpointId: string } | undefined {
4848 const c = connectionOf(db);
4949 if (!c) return undefined;
5050 const got = resolveSecrets(db, dataDir, bus, [KEY_NAME[c.mode], ENDPOINT], { actor });
src/billing/stripe.ts +25
@@ -66,6 +66,18 @@ export function formBody(data: { [k: string]: Value }): string {
6666 export interface StripeClient {
6767 addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>;
6868 removeWebhook(id: string): Promise<Result<true, StripeError>>;
69 /** The subscription as Stripe has it now. */
70 getSubscription(id: string): Promise<Result<StripeSubscription, StripeError>>;
71 }
72
73 /** The parts of a Stripe subscription the entitlement is written from. */
74 export interface StripeSubscription {
75 readonly id: string;
76 readonly status: string;
77 readonly customer: string;
78 /** Unix seconds; 0 when Stripe gave none. */
79 readonly periodEnd: number;
80 readonly metadata: Readonly<Record<string, string>>;
6981 }
7082
7183 const PERMISSION = "This key may not create webhook endpoints. Paste a signing secret from Stripe's webhook page instead.";
@@ -112,5 +124,18 @@ export function stripeClient(key: string, fetchFn: typeof fetch = fetch): Stripe
112124 const r = await call("DELETE", `/v1/webhook_endpoints/${encodeURIComponent(id)}`);
113125 return r.ok ? ok(true as const) : r;
114126 },
127 async getSubscription(id) {
128 const r = await call("GET", `/v1/subscriptions/${encodeURIComponent(id)}`);
129 if (!r.ok) return r;
130 const v = r.value;
131 if (str(v, "id") === "" || str(v, "status") === "") return err(missing("subscription"));
132 const customer = typeof v["customer"] === "object" && v["customer"] !== null ? str(v["customer"] as Record<string, unknown>, "id") : str(v, "customer");
133 // the period end is on the subscription in older API versions and on its first item in newer ones
134 const item = ((v["items"] as { data?: unknown } | undefined)?.data as Record<string, unknown>[] | undefined)?.[0];
135 const end = [v["current_period_end"], item?.["current_period_end"]].find((n) => typeof n === "number") as number | undefined;
136 const metadata: Record<string, string> = {};
137 for (const [k, x] of Object.entries((v["metadata"] ?? {}) as Record<string, unknown>)) if (typeof x === "string") metadata[k] = x;
138 return ok({ id: str(v, "id"), status: str(v, "status"), customer, periodEnd: end ?? 0, metadata });
139 },
115140 };
116141 }
src/billing/subscription.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * A Stripe subscription as an entitlement (#11245): plain data in, a `Put`
3 * out. The event that announced a change is never the source: the hook
4 * re-reads the subscription from Stripe and this turns what Stripe says now
5 * into the forge's plan and status, so events delivered out of order end on
6 * the same row.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { FREE, getPlan } from "../settings/plans.ts";
11 import type { Put } from "./store.ts";
12 import { getStored } from "./store.ts";
13 import type { StripeSubscription } from "./stripe.ts";
14
15 const FORGE = /^[a-z0-9][a-z0-9-]{0,62}$/;
16
17 /** The subscription id an event points at; the only thing read from the event body. */
18 export function subscriptionIdOf(event: unknown): string | undefined {
19 const obj = ((event as { data?: { object?: unknown } } | null)?.data?.object ?? null) as Record<string, unknown> | null;
20 if (obj === null || typeof obj !== "object") return undefined;
21 const type = (event as { type?: unknown }).type;
22 const parent = (obj["parent"] as { subscription_details?: { subscription?: unknown } } | undefined)?.subscription_details?.subscription;
23 const raw = typeof type === "string" && type.startsWith("invoice.") ? (obj["subscription"] ?? parent) : obj["id"];
24 const id = typeof raw === "object" && raw !== null ? (raw as { id?: unknown }).id : raw;
25 return typeof id === "string" && /^sub_[A-Za-z0-9_]{1,200}$/.test(id) ? id : undefined;
26 }
27
28 const STATUS = {
29 active: "active", trialing: "active", past_due: "grace",
30 canceled: "none", unpaid: "none", incomplete: "none", incomplete_expired: "none", paused: "none",
31 } as const;
32
33 /** What the forge should hold for this subscription; undefined when it names no forge we know. */
34 export function entitlementFrom(db: Database, sub: StripeSubscription): Put | undefined {
35 const named = sub.metadata["forge"] ?? "";
36 const row = getStoredBySubscription(db, sub.id);
37 const forge = row ?? (FORGE.test(named) ? named : undefined);
38 if (forge === undefined) return undefined;
39 const status = STATUS[sub.status as keyof typeof STATUS] ?? "none";
40 const wanted = sub.metadata["plan"] ?? "";
41 const kept = getStored(db, forge)?.plan;
42 // A paid status needs a plan we know; without one, write nothing rather than a paid status on the free plan.
43 const plan = status === "none" ? FREE : getPlan(db, wanted) !== undefined ? wanted : kept;
44 if (plan === undefined) return undefined;
45 return {
46 forge, plan, status, source: "stripe", provider: "stripe", customerId: sub.customer, subscriptionId: sub.id,
47 ...(sub.periodEnd > 0 ? { periodEnd: sub.periodEnd } : {}),
48 };
49 }
50
51 function getStoredBySubscription(db: Database, id: string): string | undefined {
52 return db.query<{ forge: string }, [string]>("SELECT forge FROM entitlement WHERE subscription_id = ?1").get(id)?.forge;
53 }
src/http/stripe_hook.ts +61 −2
@@ -7,11 +7,18 @@
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.
99 * `checkout.session.completed` binds its forge (`billing/checkout_completed.ts`).
10 * A `customer.subscription.*` or `invoice.*` event only names a subscription: the
11 * hook re-reads it from Stripe and writes the entitlement from that, one
12 * subscription at a time, so events out of order end on the current state.
1013 */
1114
1215 import type { App } from "../app.ts";
16 import { listChildrenNow } from "../actions/spawn.ts";
1317 import { bindCheckout } from "../billing/checkout_completed.ts";
14 import { openWebhookSecret } from "../billing/connection.ts";
18 import { openKey, openWebhookSecret } from "../billing/connection.ts";
19 import { getStored, putEntitlement } from "../billing/store.ts";
20 import { stripeClient } from "../billing/stripe.ts";
21 import { entitlementFrom, subscriptionIdOf } from "../billing/subscription.ts";
1522 import { verifyStripeSignature } from "../billing/signature.ts";
1623 import { RateLimiter } from "../core/ratelimit.ts";
1724 import type { Ctx } from "./context.ts";
@@ -47,6 +54,49 @@ export function setStripeHookCaps(app: App, perClient: number, wholeForge: numbe
4754 bucketsOf(app, perClient, wholeForge);
4855 }
4956
57 const fetches = new WeakMap<App, typeof fetch>();
58 /** Replace the fetch this app's hook reads Stripe with; for tests. */
59 export function setStripeHookFetch(app: App, f: typeof fetch | undefined): void {
60 if (f === undefined) fetches.delete(app);
61 else fetches.set(app, f);
62 }
63
64 const chains = new Map<string, Promise<void>>();
65 /** Run `job` after every earlier job for the same key finished, so read and write are one critical section. */
66 function serialised(key: string, job: () => Promise<void>): Promise<void> {
67 const run = (chains.get(key) ?? Promise.resolve()).then(job, job);
68 const tail = run.catch(() => {});
69 chains.set(key, tail);
70 void tail.then(() => {
outdated

Read and write are separated by an await with no per-subscription lock: concurrent events can write stale state last. Serialise per subscription id.

71 if (chains.get(key) === tail) chains.delete(key);
72 });
73 return run;
74 }
75
outdated

Size falls back to "small" when the forge is unknown to listChildrenNow; this can overwrite a real size. Preserve the stored size.

76 /**
77 * Write the entitlement from the subscription as Stripe has it now, never from
78 * the event's body. Reads and writes for one subscription run one at a time, so
79 * the last write is always from the last read. Stripe unreachable or refusing
80 * leaves the entitlement as it was (and is audited): the hook still answers
81 * 200, so Stripe does not retry into a stale read.
82 */
83 async function syncSubscription(app: App, event: unknown): Promise<void> {
84 const id = subscriptionIdOf(event);
85 const opened = openKey(app.db, app.config.dataDir, app.bus, undefined);
86 if (id === undefined || opened === undefined) return;
87 await serialised(id, async () => {
88 const read = await stripeClient(opened.key, fetches.get(app) ?? fetch).getSubscription(id);
89 if (!read.ok) {
90 app.bus.append({ kind: "billing.sync_failed", payload: { subscription: id, reason: read.error.kind } });
91 return;
92 }
93 const put = entitlementFrom(app.db, read.value);
94 if (put === undefined) return;
95 const size = listChildrenNow(app).find((c) => c.name === put.forge)?.size ?? getStored(app.db, put.forge)?.plan ?? "small";
96 putEntitlement(app.db, put, size, "stripe");
97 });
98 }
99
50100 const answer = (status: number, headers?: Record<string, string>): Response =>
51101 new Response(null, { status, headers: { "cache-control": "no-store", ...headers } });
52102
@@ -101,9 +151,18 @@ export async function stripeHook(ctx: Ctx): Promise<Response> {
101151 const type = typeof e.type === "string" && EVENT_TYPE.test(e.type) ? e.type : "unknown";
102152 // A repeated id changes nothing: the primary key refuses it.
103153 // One transaction: if binding throws, the id is not kept, so Stripe's retry is not taken for a replay.
104 app.db.transaction(() => {
154 const fresh = app.db.transaction(() => {
105155 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());
106156 if (stored.changes === 1 && type === "checkout.session.completed") bindCheckout(app, event);
157 return stored.changes === 1;
107158 })();
159 if (fresh && (type.startsWith("customer.subscription.") || type.startsWith("invoice."))) {
160 try {
161 await syncSubscription(app, event);
162 } catch (cause) {
163 // never let a read of Stripe turn into a retry storm; the audit says it failed
164 app.bus.append({ kind: "billing.sync_failed", payload: { event: e.id, reason: cause instanceof Error ? cause.name : "error" } });
165 }
166 }
108167 return answer(200);
109168 }
test/stripe-hook.test.ts +82 −3
@@ -6,9 +6,9 @@ import { tmpdir } from "node:os";
66 import { join } from "node:path";
77 import { createApp, type App } from "../src/app.ts";
88 import { sealKey, sealWebhook } from "../src/billing/connection.ts";
9 import { getStored } from "../src/billing/store.ts";
109 import { serve, type Servers } from "../src/http/server.ts";
11 import { refusedCount, setStripeHookCaps } from "../src/http/stripe_hook.ts";
10 import { getStored } from "../src/billing/store.ts";
11 import { refusedCount, setStripeHookCaps, setStripeHookFetch } from "../src/http/stripe_hook.ts";
1212
1313 const secret = "whsec_hook_test_secret";
1414 let dir: string;
@@ -113,7 +113,7 @@ describe("POST /hooks/stripe", () => {
113113 const before = reads();
114114 expect((await post("{}", "t=1,v1=00")).status).toBe(400);
115115 expect(reads()).toBe(before);
116 const body = evt("evt_audit");
116 const body = evt("evt_audit", "ping.neutral"); // a type the hook does not act on, so only the signing secret is opened
117117 expect((await post(body, await sign(body))).status).toBe(200);
118118 expect(reads()).toBe(before + 1);
119119 });
@@ -136,6 +136,85 @@ describe("POST /hooks/stripe", () => {
136136 expect(old()).toBeNull();
137137 });
138138
139 describe("subscription and invoice events", () => {
140 let nowSub: { status: string };
141 let calls: string[];
142 let down = false;
143 const sub = () => ({ id: "sub_1", status: nowSub.status, customer: "cus_1", metadata: { forge: "acme", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } });
144 const ev = (id: string, type: string, status: string) =>
145 JSON.stringify({ id, type, data: { object: type.startsWith("invoice.") ? { id: "in_1", subscription: "sub_1", status } : { id: "sub_1", status } } });
146 const deliver = async (body: string) => (await post(body, await sign(body))).status;
147 beforeAll(() => {
148 setStripeHookFetch(app, (async (url: string) => {
149 calls.push(String(url));
150 if (down) throw new Error("offline");
151 return new Response(JSON.stringify(sub()), { status: 200 });
152 }) as unknown as typeof fetch);
153 });
154 afterAll(() => setStripeHookFetch(app, undefined));
155
156 test("events in reverse order end on what Stripe says now, not on the event bodies", async () => {
157 calls = [];
158 nowSub = { status: "active" };
159 // the stale `past_due` event arrives last; its own body says past_due, Stripe says active
160 expect(await deliver(ev("evt_s2", "invoice.paid", "paid"))).toBe(200);
161 expect(await deliver(ev("evt_s1", "customer.subscription.updated", "past_due"))).toBe(200);
162 expect(calls).toEqual(["https://api.stripe.com/v1/subscriptions/sub_1", "https://api.stripe.com/v1/subscriptions/sub_1"]);
163 expect(getStored(app.db, "acme")).toMatchObject({ plan: "medium", status: "active", periodEnd: 1900000000, source: "stripe", provider: "stripe", customerId: "cus_1", subscriptionId: "sub_1" });
164 });
165
166 test("Stripe unreachable leaves the entitlement as it was and answers 200", async () => {
167 const before = getStored(app.db, "acme");
168 nowSub = { status: "canceled" };
169 down = true;
170 expect(await deliver(ev("evt_s3", "customer.subscription.deleted", "canceled"))).toBe(200);
171 down = false;
172 expect(getStored(app.db, "acme")).toEqual(before!);
173 });
174
175 test("a later event re-reads and follows Stripe to canceled", async () => {
176 nowSub = { status: "canceled" };
177 expect(await deliver(ev("evt_s4", "invoice.payment_failed", "open"))).toBe(200);
178 expect(getStored(app.db, "acme")).toMatchObject({ plan: "free", status: "none" });
179 });
180
181 test("a replayed event id does not read Stripe again", async () => {
182 calls = [];
183 nowSub = { status: "active" };
184 const body = ev("evt_r1", "invoice.paid", "paid");
185 const header = await sign(body);
186 expect((await post(body, header)).status).toBe(200);
187 expect((await post(body, header)).status).toBe(200);
188 expect(calls.length).toBe(1);
189 });
190
191 test("deliveries for one subscription are serialised: a slow older read cannot land last", async () => {
192 const gates: Array<(status: string) => void> = [];
193 setStripeHookFetch(app, (async () =>
194 new Promise<Response>((resolve) => {
195 gates.push((status) => resolve(new Response(JSON.stringify({ ...sub(), status }), { status: 200 })));
196 })) as unknown as typeof fetch);
197 const first = deliver(ev("evt_c1", "customer.subscription.updated", "x"));
198 await Bun.sleep(30);
199 const second = deliver(ev("evt_c2", "customer.subscription.updated", "x"));
200 await Bun.sleep(30);
201 expect(gates.length).toBe(1); // the second read waits for the first write
202 gates[0]!("past_due"); // the first read is the older state
203 await Bun.sleep(30);
204 expect(gates.length).toBe(2);
205 gates[1]!("active");
206 expect([await first, await second]).toEqual([200, 200]);
207 expect(getStored(app.db, "acme")).toMatchObject({ status: "active" });
208 });
209
210 test("a failed read is audited", async () => {
211 setStripeHookFetch(app, (async () => { throw new Error("offline"); }) as unknown as typeof fetch);
212 expect(await deliver(ev("evt_f1", "invoice.paid", "paid"))).toBe(200);
213 const n = app.db.query<{ n: number }, []>("SELECT count(*) n FROM event WHERE kind = 'billing.sync_failed'").get()?.n ?? 0;
214 expect(n).toBeGreaterThan(0);
215 });
216 });
217
139218 describe("checkout.session.completed", () => {
140219 const session = (id: string, forge: unknown, extra: Record<string, unknown> = {}) =>
141220 JSON.stringify({
test/subscription.test.ts added +72
@@ -0,0 +1,72 @@
1 // A Stripe subscription as an entitlement (#11245): which subscription an event names, how Stripe's answer is read, what is written.
2 import { describe, expect, test } from "bun:test";
3 import { Database } from "bun:sqlite";
4 import { createApp } from "../src/app.ts";
5 import { putEntitlement } from "../src/billing/store.ts";
6 import { stripeClient, type StripeSubscription } from "../src/billing/stripe.ts";
7 import { entitlementFrom, subscriptionIdOf } from "../src/billing/subscription.ts";
8 import { mkdtempSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11
12 describe("subscriptionIdOf", () => {
13 const ev = (type: string, object: unknown) => ({ type, data: { object } });
14 test("a subscription event names its own id; an invoice names its subscription", () => {
15 expect(subscriptionIdOf(ev("customer.subscription.updated", { id: "sub_1" }))).toBe("sub_1");
16 expect(subscriptionIdOf(ev("invoice.paid", { id: "in_1", subscription: "sub_2" }))).toBe("sub_2");
17 });
18 test("an invoice in the newer shape names it under parent.subscription_details", () => {
19 expect(subscriptionIdOf(ev("invoice.paid", { id: "in_1", parent: { subscription_details: { subscription: "sub_3" } } }))).toBe("sub_3");
20 });
21 test("an expanded object gives its id", () => {
22 expect(subscriptionIdOf(ev("invoice.paid", { subscription: { id: "sub_4" } }))).toBe("sub_4");
23 });
24 test("malformed or non-sub_ ids name nothing", () => {
25 expect(subscriptionIdOf(ev("invoice.paid", { id: "in_1" }))).toBeUndefined();
26 expect(subscriptionIdOf(ev("invoice.paid", { subscription: "cus_1" }))).toBeUndefined();
27 expect(subscriptionIdOf(ev("customer.subscription.updated", { id: "sub_../x" }))).toBeUndefined();
28 expect(subscriptionIdOf(ev("customer.subscription.updated", { id: 5 }))).toBeUndefined();
29 expect(subscriptionIdOf(ev("invoice.paid", { subscription: null }))).toBeUndefined();
30 expect(subscriptionIdOf({ type: "invoice.paid" })).toBeUndefined();
31 expect(subscriptionIdOf(null)).toBeUndefined();
32 });
33 });
34
35 describe("getSubscription", () => {
36 const read = (body: unknown) =>
37 stripeClient("sk_test_abcdefgh12345678", (async () => new Response(JSON.stringify(body), { status: 200 })) as unknown as typeof fetch).getSubscription("sub_1");
38 test("a customer object gives its id; the period end on an item is read", async () => {
39 const r = await read({ id: "sub_1", status: "active", customer: { id: "cus_9" }, items: { data: [{ current_period_end: 42 }] }, metadata: { forge: "a", n: 1 } });
40 expect(r).toEqual({ ok: true, value: { id: "sub_1", status: "active", customer: "cus_9", periodEnd: 42, metadata: { forge: "a" } } });
41 });
42 test("the period end on the subscription wins over none on the item", async () => {
43 const r = await read({ id: "sub_1", status: "active", customer: "cus_1", current_period_end: 7 });
44 expect(r).toMatchObject({ ok: true, value: { periodEnd: 7, customer: "cus_1" } });
45 });
46 test("a missing id or status is an error", async () => {
47 expect((await read({ status: "active" })).ok).toBe(false);
48 expect((await read({ id: "sub_1" })).ok).toBe(false);
49 });
50 });
51
52 describe("entitlementFrom", () => {
53 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "sub-")), port: 0 });
54 const db: Database = app.db;
55 const sub = (o: Partial<StripeSubscription>): StripeSubscription => ({ id: "sub_1", status: "active", customer: "cus_1", periodEnd: 0, metadata: {}, ...o });
56 test("a forge nobody named writes nothing", () => {
57 expect(entitlementFrom(db, sub({}))).toBeUndefined();
58 expect(entitlementFrom(db, sub({ metadata: { forge: "Bad Name!", plan: "medium" } }))).toBeUndefined();
59 });
60 test("a metadata forge with a known plan is written", () => {
61 expect(entitlementFrom(db, sub({ metadata: { forge: "acme", plan: "medium" }, periodEnd: 5 }))).toMatchObject({ forge: "acme", plan: "medium", status: "active", periodEnd: 5 });
62 });
63 test("an unknown plan with no row writes nothing while paid, and free when not", () => {
64 expect(entitlementFrom(db, sub({ metadata: { forge: "acme", plan: "nope" } }))).toBeUndefined();
65 expect(entitlementFrom(db, sub({ status: "canceled", metadata: { forge: "acme", plan: "nope" } }))).toMatchObject({ plan: "free", status: "none" });
66 });
67 test("a known forge is found by its stored subscription id, and keeps its plan when the metadata plan is unknown", () => {
68 putEntitlement(db, { forge: "acme", plan: "medium", status: "active", source: "stripe", provider: "stripe", subscriptionId: "sub_1" }, "small", "t");
69 expect(entitlementFrom(db, sub({ status: "past_due" }))).toMatchObject({ forge: "acme", plan: "medium", status: "grace" });
70 expect(entitlementFrom(db, sub({ metadata: { plan: "nope" } }))).toMatchObject({ forge: "acme", plan: "medium", status: "active" });
71 });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs