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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11245-a-stripe-subscription-or-invoice-event-w → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 44 | 44 | } |
| 45 | 45 | |
| 46 | 46 | /** 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 { | |
| 48 | 48 | const c = connectionOf(db); |
| 49 | 49 | if (!c) return undefined; |
| 50 | 50 | 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 { | ||
| 66 | 66 | export interface StripeClient { |
| 67 | 67 | addWebhook(url: string): Promise<Result<{ id: string; secret: string }, StripeError>>; |
| 68 | 68 | 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>>; | |
| 69 | 81 | } |
| 70 | 82 | |
| 71 | 83 | 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 | ||
| 112 | 124 | const r = await call("DELETE", `/v1/webhook_endpoints/${encodeURIComponent(id)}`); |
| 113 | 125 | return r.ok ? ok(true as const) : r; |
| 114 | 126 | }, |
| 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 | }, | |
| 115 | 140 | }; |
| 116 | 141 | } |
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 @@ | ||
| 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 | 9 | * `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. | |
| 10 | 13 | */ |
| 11 | 14 | |
| 12 | 15 | import type { App } from "../app.ts"; |
| 16 | import { listChildrenNow } from "../actions/spawn.ts"; | |
| 13 | 17 | 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"; | |
| 15 | 22 | import { verifyStripeSignature } from "../billing/signature.ts"; |
| 16 | 23 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 17 | 24 | import type { Ctx } from "./context.ts"; |
| @@ -47,6 +54,49 @@ export function setStripeHookCaps(app: App, perClient: number, wholeForge: numbe | ||
| 47 | 54 | bucketsOf(app, perClient, wholeForge); |
| 48 | 55 | } |
| 49 | 56 | |
| 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(() => { | |
| 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 | ||
| 50 | 100 | const answer = (status: number, headers?: Record<string, string>): Response => |
| 51 | 101 | new Response(null, { status, headers: { "cache-control": "no-store", ...headers } }); |
| 52 | 102 | |
| @@ -101,9 +151,18 @@ export async function stripeHook(ctx: Ctx): Promise<Response> { | ||
| 101 | 151 | const type = typeof e.type === "string" && EVENT_TYPE.test(e.type) ? e.type : "unknown"; |
| 102 | 152 | // A repeated id changes nothing: the primary key refuses it. |
| 103 | 153 | // 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(() => { | |
| 105 | 155 | 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 | 156 | if (stored.changes === 1 && type === "checkout.session.completed") bindCheckout(app, event); |
| 157 | return stored.changes === 1; | |
| 107 | 158 | })(); |
| 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 | } | |
| 108 | 167 | return answer(200); |
| 109 | 168 | } |
test/stripe-hook.test.ts +82 −3
| @@ -6,9 +6,9 @@ import { tmpdir } from "node:os"; | ||
| 6 | 6 | import { join } from "node:path"; |
| 7 | 7 | import { createApp, type App } from "../src/app.ts"; |
| 8 | 8 | import { sealKey, sealWebhook } from "../src/billing/connection.ts"; |
| 9 | import { getStored } from "../src/billing/store.ts"; | |
| 10 | 9 | 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"; | |
| 12 | 12 | |
| 13 | 13 | const secret = "whsec_hook_test_secret"; |
| 14 | 14 | let dir: string; |
| @@ -113,7 +113,7 @@ describe("POST /hooks/stripe", () => { | ||
| 113 | 113 | const before = reads(); |
| 114 | 114 | expect((await post("{}", "t=1,v1=00")).status).toBe(400); |
| 115 | 115 | 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 | |
| 117 | 117 | expect((await post(body, await sign(body))).status).toBe(200); |
| 118 | 118 | expect(reads()).toBe(before + 1); |
| 119 | 119 | }); |
| @@ -136,6 +136,85 @@ describe("POST /hooks/stripe", () => { | ||
| 136 | 136 | expect(old()).toBeNull(); |
| 137 | 137 | }); |
| 138 | 138 | |
| 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 | ||
| 139 | 218 | describe("checkout.session.completed", () => { |
| 140 | 219 | const session = (id: string, forge: unknown, extra: Record<string, unknown> = {}) => |
| 141 | 220 | 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 | }); |
Read and write are separated by an await with no per-subscription lock: concurrent events can write stale state last. Serialise per subscription id.