import { beforeEach, describe, expect, mock, test } from "bun:test"; import Stripe from "stripe"; import { fakeD1 } from "../metering/fake-d1.test-helper"; let d1 = fakeD1(); const kv = new Map(); const SECRET = "whsec_test_secret"; const fakeEnv: Record = { get DB() { return d1.binding; }, KV: { get: async (key: string) => kv.get(key) ?? null, put: async (key: string, value: string) => void kv.set(key, value), delete: async (key: string) => void kv.delete(key), }, METERING_PLANS: "", PUBLIC_URL: "https://forge.test", STRIPE_SECRET_KEY: "sk_test_unit", STRIPE_WEBHOOK_SECRET: SECRET, STRIPE_PRICE_PRO: "price_pro", STRIPE_TOPUP_PRICES: JSON.stringify({ compute_10h: "price_c", tokens_10m: { price: "price_t" }, tokens_50m: "nope", unknown_pack: "price_x", }), }; mock.module("cloudflare:workers", () => ({ env: fakeEnv, waitUntil: () => undefined })); const disabled: { accountId: string; reason: string }[] = []; const adminNotices: string[] = []; mock.module("../account-disable-actions", () => ({ disableAccount: async (input: { accountId: string; reason: string }) => { disabled.push({ accountId: input.accountId, reason: input.reason }); return true; }, notifyAdminsAboutAccount: async (_accountId: string, title: string) => void adminNotices.push(title), })); const { handleStripeWebhook, processStripeEvent } = await import("./webhook"); const { topupPacks } = await import("./config"); const { creditBalance } = await import("../metering/credits"); const signer = new Stripe("sk_test_unit"); const crypto = Stripe.createSubtleCryptoProvider(); let sequence = 0; function event(type: string, object: Record, created = 1_700_000_000 + sequence): Stripe.Event { sequence += 1; return { id: `evt_${sequence}`, object: "event", type, created, livemode: false, data: { object }, } as unknown as Stripe.Event; } async function post(payload: Stripe.Event, secret = SECRET): Promise { const body = JSON.stringify(payload); const signature = await signer.webhooks.generateTestHeaderStringAsync({ payload: body, secret, cryptoProvider: crypto, }); return handleStripeWebhook( new Request("https://forge.test/stripe/webhook", { method: "POST", headers: { "stripe-signature": signature }, body, }), ); } function plan(accountId: string): string | null { const row = d1.db.query("SELECT plan FROM account_billing WHERE account_id = ?").get(accountId) as { plan: string; } | null; return row?.plan ?? null; } function subscription(status: string, extra: Record = {}) { return { id: "sub_1", object: "subscription", customer: "cus_1", status, cancel_at_period_end: false, metadata: { account_id: "o1" }, items: { data: [{ price: { id: "price_pro" }, current_period_end: 1_800_000_000 }] }, ...extra, }; } beforeEach(() => { d1 = fakeD1(); kv.clear(); disabled.length = 0; adminNotices.length = 0; d1.db.exec("INSERT INTO accounts (id, name, kind) VALUES ('u1', 'alice', 'user'), ('o1', 'acme', 'org');"); }); describe("stripe webhook", () => { test("rejects missing and wrong signatures", async () => { const payload = event("customer.subscription.created", subscription("active")); const unsigned = await handleStripeWebhook( new Request("https://forge.test/stripe/webhook", { method: "POST", body: JSON.stringify(payload) }), ); expect(unsigned.status).toBe(400); expect((await post(payload, "whsec_other")).status).toBe(400); expect(plan("o1")).toBeNull(); expect((await post(payload)).status).toBe(200); expect(plan("o1")).toBe("pro"); }); test("the custom domain add-on is tracked on its own and never changes the plan", async () => { fakeEnv.STRIPE_PRICE_CUSTOM_DOMAIN = "price_domain"; const addon = subscription("active", { id: "sub_domains", metadata: { account_id: "o1", kind: "custom_domains" }, items: { data: [{ id: "si_d", quantity: 2, price: { id: "price_domain" }, current_period_end: 1_800_000_000 }] }, }); await processStripeEvent( event("checkout.session.completed", { id: "cs_d", object: "checkout.session", mode: "subscription", status: "complete", customer: "cus_1", subscription: "sub_domains", metadata: { account_id: "o1", kind: "custom_domains" }, }), ); await processStripeEvent(event("customer.subscription.created", addon, 100)); expect(plan("o1")).toBeNull(); const row = () => d1.db.query("SELECT status, item_id, quantity, lapsed_at FROM billing_addons WHERE account_id = 'o1'").get() as { status: string; item_id: string; quantity: number; lapsed_at: number | null; }; expect(row()).toMatchObject({ status: "active", item_id: "si_d", quantity: 2, lapsed_at: null }); await processStripeEvent(event("customer.subscription.updated", { ...addon, status: "unpaid" }, 200)); expect(row().lapsed_at).toBeGreaterThan(0); await processStripeEvent(event("customer.subscription.updated", addon, 150)); expect(row().status).toBe("unpaid"); await processStripeEvent(event("customer.subscription.updated", addon, 300)); expect(row()).toMatchObject({ status: "active", lapsed_at: null }); delete fakeEnv.STRIPE_PRICE_CUSTOM_DOMAIN; }); test("is idempotent on event id", async () => { const payload = event("customer.subscription.created", subscription("active")); expect(await processStripeEvent(payload)).toBe("processed"); expect(await processStripeEvent(payload)).toBe("duplicate"); }); test("subscribers on an earlier Pro price stay Pro after a price change", async () => { const legacy = subscription("active", { items: { data: [ { price: { id: "price_old", metadata: { plan: "pro" }, recurring: { interval: "month" } }, current_period_end: 1_800_000_000, }, ], }, }); await processStripeEvent(event("customer.subscription.updated", legacy)); expect(plan("o1")).toBe("pro"); const other = subscription("active", { id: "sub_1", items: { data: [{ price: { id: "price_other", metadata: {} }, current_period_end: 1_800_000_000 }] }, }); await processStripeEvent(event("customer.subscription.updated", other)); expect(plan("o1")).toBe("free"); }); test("subscription lifecycle drives the plan, ignoring stale events", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"), 100)); expect(plan("o1")).toBe("pro"); await processStripeEvent(event("customer.subscription.deleted", subscription("canceled"), 300)); expect(plan("o1")).toBe("free"); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 200)); expect(plan("o1")).toBe("free"); }); test("checkout completion upgrades at once and maps the customer to the org", async () => { await processStripeEvent( event("checkout.session.completed", { id: "cs_1", object: "checkout.session", mode: "subscription", status: "complete", payment_status: "paid", customer: "cus_9", subscription: "sub_9", metadata: { account_id: "o1", kind: "pro" }, }), ); expect(plan("o1")).toBe("pro"); const row = d1.db.query("SELECT account_id FROM billing_customers WHERE stripe_customer_id = 'cus_9'").get(); expect(row).toEqual({ account_id: "o1" }); }); test("a returning customer's new checkout replaces the ended subscription at once", async () => { await processStripeEvent(event("customer.subscription.deleted", subscription("canceled"))); expect(plan("o1")).toBe("free"); await processStripeEvent( event("checkout.session.completed", { id: "cs_2", mode: "subscription", status: "complete", payment_status: "paid", customer: "cus_1", subscription: "sub_2", metadata: { account_id: "o1", kind: "pro" }, }), ); expect(plan("o1")).toBe("pro"); }); test("metadata can't move an already-mapped customer to another account", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"))); await processStripeEvent( event("customer.subscription.updated", subscription("active", { metadata: { account_id: "u1" } })), ); expect(plan("u1")).toBeNull(); }); test("failed payments mark past_due; the subscription status decides the plan", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"))); const invoice = (subscriptionId: string, next: number | null) => ({ customer: "cus_1", collection_method: "charge_automatically", next_payment_attempt: next, parent: { subscription_details: { subscription: subscriptionId } }, }); const status = () => ( d1.db.query("SELECT subscription_status AS s FROM billing_customers WHERE account_id = 'o1'").get() as { s: string; } ).s; await processStripeEvent(event("invoice.payment_failed", invoice("sub_other", null))); expect(status()).toBe("active"); await processStripeEvent(event("invoice.payment_failed", invoice("sub_1", null))); expect(status()).toBe("past_due"); expect(plan("o1")).toBe("pro"); await processStripeEvent(event("customer.subscription.updated", subscription("unpaid"))); expect(plan("o1")).toBe("free"); // An older "active" update delivered late can't undo the downgrade. await processStripeEvent(event("customer.subscription.updated", subscription("active"), 1_600_000_000)); expect(plan("o1")).toBe("free"); }); test("paid top-ups add credit once; refunds and disputes reverse unused credit", async () => { const session = { id: "cs_top", object: "checkout.session", mode: "payment", status: "complete", payment_status: "paid", customer: "cus_2", payment_intent: "pi_1", amount_total: 1000, metadata: { account_id: "u1", kind: "topup", pack: "compute_s", meter: "compute.runner", quantity: "36000" }, }; await processStripeEvent(event("checkout.session.completed", session)); await processStripeEvent(event("checkout.session.completed", session)); expect(await creditBalance("u1", "compute")).toBe(36_000); await processStripeEvent( event("charge.refunded", { id: "ch_1", payment_intent: "pi_1", amount: 1000, amount_refunded: 500 }), ); expect(await creditBalance("u1", "compute")).toBe(18_000); await processStripeEvent(event("charge.dispute.created", { id: "dp_1", payment_intent: "pi_1" })); expect(await creditBalance("u1", "compute")).toBe(0); }); test("reversing one pack never takes credit bought with another", async () => { const pack = (id: string, pi: string) => ({ id, mode: "payment", status: "complete", payment_status: "paid", customer: "cus_2", payment_intent: pi, amount_total: 1000, metadata: { account_id: "u1", kind: "topup", pack: "compute_s", meter: "compute.runner", quantity: "1000" }, }); await processStripeEvent(event("checkout.session.completed", pack("cs_a", "pi_a"))); d1.db.exec("UPDATE credit_balances SET balance = 0"); await processStripeEvent( event("charge.refunded", { id: "ch_a", payment_intent: "pi_a", amount: 1000, amount_refunded: 500 }), ); await processStripeEvent(event("checkout.session.completed", pack("cs_b", "pi_b"))); await processStripeEvent( event("charge.refunded", { id: "ch_a", payment_intent: "pi_a", amount: 1000, amount_refunded: 1000 }), ); await processStripeEvent(event("charge.dispute.created", { id: "dp_a", payment_intent: "pi_a" })); expect(await creditBalance("u1", "compute")).toBe(1000); }); test("a partial refund of a partly spent pack only takes its own increment", async () => { const session = { id: "cs_p", mode: "payment", status: "complete", payment_status: "paid", customer: "cus_2", payment_intent: "pi_p", amount_total: 1000, metadata: { account_id: "u1", kind: "topup", pack: "compute_s", meter: "compute.runner", quantity: "1000" }, }; await processStripeEvent(event("checkout.session.completed", session)); d1.db.exec("UPDATE credit_balances SET balance = 200"); await processStripeEvent( event("charge.refunded", { id: "ch_p", payment_intent: "pi_p", amount: 1000, amount_refunded: 500 }), ); expect(await creditBalance("u1", "compute")).toBe(0); d1.db.exec("UPDATE credit_balances SET balance = 700"); await processStripeEvent( event("charge.refunded", { id: "ch_p", payment_intent: "pi_p", amount: 1000, amount_refunded: 600 }), ); expect(await creditBalance("u1", "compute")).toBe(600); }); test("a dispute disables the account behind the charge's customer; winning it does not re-enable", async () => { d1.db.exec("INSERT INTO billing_customers (account_id, stripe_customer_id, updated_at) VALUES ('o1', 'cus_9', 0)"); const dispute = { id: "dp_x", payment_intent: null, charge: { id: "ch_x", customer: "cus_9" } }; await processStripeEvent(event("charge.dispute.created", dispute)); expect(disabled).toEqual([{ accountId: "o1", reason: "payment_dispute" }]); await processStripeEvent(event("charge.dispute.closed", { ...dispute, status: "won" })); expect(disabled).toHaveLength(1); await processStripeEvent(event("charge.dispute.created", dispute)); expect(disabled).toHaveLength(1); await processStripeEvent(event("charge.dispute.created", { ...dispute, id: "dp_y" })); expect(disabled).toHaveLength(2); expect(adminNotices[0]).toContain("dp_x"); }); test("a dispute without a mapped customer falls back to the account that bought the credit", async () => { await processStripeEvent( event("checkout.session.completed", { id: "cs_f", mode: "payment", status: "complete", payment_status: "paid", customer: null, payment_intent: "pi_f", amount_total: 1000, metadata: { account_id: "u1", kind: "topup", pack: "compute_s", meter: "compute.runner", quantity: "10" }, }), ); await processStripeEvent(event("charge.dispute.created", { id: "dp_f", payment_intent: "pi_f" })); expect(disabled).toEqual([{ accountId: "u1", reason: "payment_dispute" }]); }); test("a won dispute restores what it took", async () => { const session = { id: "cs_d", mode: "payment", status: "complete", payment_status: "paid", customer: "cus_2", payment_intent: "pi_d", amount_total: 1000, metadata: { account_id: "u1", kind: "topup", pack: "compute_s", meter: "compute.runner", quantity: "1000" }, }; await processStripeEvent(event("checkout.session.completed", session)); await processStripeEvent(event("charge.dispute.created", { id: "dp_d", payment_intent: "pi_d" })); expect(await creditBalance("u1", "compute")).toBe(0); await processStripeEvent(event("charge.dispute.closed", { id: "dp_d", payment_intent: "pi_d", status: "won" })); await processStripeEvent(event("charge.dispute.closed", { id: "dp_d", payment_intent: "pi_d", status: "won" })); expect(await creditBalance("u1", "compute")).toBe(1000); // The pack is whole again, so a later refund still reverses it. await processStripeEvent( event("charge.refunded", { id: "ch_d", payment_intent: "pi_d", amount: 1000, amount_refunded: 1000 }), ); expect(await creditBalance("u1", "compute")).toBe(0); }); test("unpaid top-ups add nothing", async () => { await processStripeEvent( event("checkout.session.completed", { id: "cs_x", mode: "payment", payment_status: "unpaid", customer: "cus_3", metadata: { account_id: "u1", kind: "topup", meter: "compute.runner", quantity: "10" }, }), ); expect(await creditBalance("u1", "compute")).toBe(0); }); test("top-up packs drop malformed entries", () => { expect(topupPacks().map((pack) => pack.id)).toEqual(["compute_10h", "tokens_10m"]); expect(topupPacks()[0]).toMatchObject({ pool: "compute", quantity: 36_000, price: "price_c" }); }); test("without Stripe the webhook does not exist", async () => { const key = fakeEnv.STRIPE_SECRET_KEY; delete fakeEnv.STRIPE_SECRET_KEY; try { const response = await post(event("customer.subscription.created", subscription("active"))); expect(response.status).toBe(404); } finally { fakeEnv.STRIPE_SECRET_KEY = key; } }); }); describe("release hardening", () => { const invoice = (subscriptionId: string) => ({ customer: "cus_1", parent: { subscription_details: { subscription: subscriptionId } }, }); const status = () => ( d1.db.query("SELECT subscription_status AS s FROM billing_customers WHERE account_id = 'o1'").get() as { s: string; } ).s; test("events from the other Stripe mode are ignored", async () => { const live = { ...event("customer.subscription.created", subscription("active")), livemode: true }; expect(await processStripeEvent(live as Stripe.Event)).toBe("ignored"); expect(plan("o1")).toBeNull(); expect((await post(live as Stripe.Event)).status).toBe(200); expect(plan("o1")).toBeNull(); }); test("invoice.paid recovers a past_due subscription; an older one cannot", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"), 100)); await processStripeEvent(event("invoice.payment_failed", invoice("sub_1"), 200)); expect(status()).toBe("past_due"); await processStripeEvent(event("invoice.paid", invoice("sub_other"), 300)); expect(status()).toBe("past_due"); await processStripeEvent(event("invoice.paid", invoice("sub_1"), 50)); expect(status()).toBe("past_due"); expect(await processStripeEvent(event("invoice.paid", invoice("sub_1"), 300))).toBe("processed"); expect(status()).toBe("active"); expect(plan("o1")).toBe("pro"); }); test("past_due_since starts once and is not reset by later past_due updates", async () => { const since = () => ( d1.db.query("SELECT past_due_since AS v FROM billing_customers WHERE account_id = 'o1'").get() as { v: number | null; } ).v; await processStripeEvent(event("customer.subscription.created", subscription("active"), 100)); expect(since()).toBeNull(); await processStripeEvent(event("invoice.payment_failed", invoice("sub_1"), 200)); expect(since()).toBe(200_000); await processStripeEvent(event("customer.subscription.updated", subscription("past_due"), 300)); await processStripeEvent(event("customer.subscription.updated", subscription("past_due"), 400)); expect(since()).toBe(200_000); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 500)); expect(since()).toBeNull(); }); test("an older out-of-order subscription event cannot undo a completed checkout", async () => { const checkout = { id: "cs_order", mode: "subscription", status: "complete", payment_status: "paid", customer: "cus_1", subscription: "sub_1", metadata: { account_id: "o1", kind: "pro" }, }; await processStripeEvent(event("checkout.session.completed", checkout, 500)); expect(plan("o1")).toBe("pro"); await processStripeEvent(event("customer.subscription.created", subscription("incomplete"), 400)); expect(plan("o1")).toBe("pro"); expect(status()).toBe("active"); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 600)); const row = d1.db.query("SELECT current_period_end AS e FROM billing_customers WHERE account_id = 'o1'").get() as { e: number; }; expect(row.e).toBe(1_800_000_000_000); }); test("same-second checkout and incomplete subscription events end active in either order", async () => { const checkout = { id: "cs_tie", mode: "subscription", status: "complete", payment_status: "paid", customer: "cus_1", subscription: "sub_1", metadata: { account_id: "o1", kind: "pro" }, }; const incomplete = () => event("customer.subscription.created", subscription("incomplete"), 700); const completed = () => event("checkout.session.completed", { ...checkout, id: `cs_tie_${sequence}` }, 700); // Checkout first, then the stale incomplete from the same second. await processStripeEvent(completed()); await processStripeEvent(incomplete()); expect(status()).toBe("active"); expect(plan("o1")).toBe("pro"); // Reset, then the incomplete first and the checkout after it. d1.db.exec("DELETE FROM billing_customers; DELETE FROM account_billing;"); await processStripeEvent(incomplete()); expect(status()).toBe("incomplete"); await processStripeEvent(completed()); expect(status()).toBe("active"); expect(plan("o1")).toBe("pro"); // A same-second active update still applies; neither incomplete nor incomplete_expired can undo active. await processStripeEvent(event("customer.subscription.updated", subscription("active"), 700)); await processStripeEvent(event("customer.subscription.updated", subscription("incomplete"), 700)); expect(status()).toBe("active"); await processStripeEvent(event("customer.subscription.updated", subscription("incomplete_expired"), 700)); expect(status()).toBe("active"); }); test("same-second recovery and resume reach active; same-second failures still move forward", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"), 800)); await processStripeEvent(event("customer.subscription.updated", subscription("past_due"), 800)); expect(status()).toBe("past_due"); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 800)); expect(status()).toBe("active"); await processStripeEvent(event("customer.subscription.updated", subscription("paused"), 900)); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 900)); expect(status()).toBe("active"); await processStripeEvent(event("customer.subscription.updated", subscription("unpaid"), 1000)); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 1000)); expect(status()).toBe("active"); await processStripeEvent(event("customer.subscription.deleted", subscription("canceled"), 1100)); await processStripeEvent(event("customer.subscription.updated", subscription("active"), 1100)); expect(status()).toBe("canceled"); }); test("incomplete_expired follows incomplete at the same second", async () => { await processStripeEvent(event("customer.subscription.created", subscription("incomplete"), 1200)); await processStripeEvent(event("customer.subscription.updated", subscription("incomplete_expired"), 1200)); expect(status()).toBe("incomplete_expired"); expect(plan("o1")).toBe("free"); }); test("same-second invoice.paid and payment_failed: the payment wins in either order", async () => { await processStripeEvent(event("customer.subscription.created", subscription("active"), 100)); await processStripeEvent(event("invoice.payment_failed", invoice("sub_1"), 200)); await processStripeEvent(event("invoice.paid", invoice("sub_1"), 200)); expect(status()).toBe("active"); await processStripeEvent(event("invoice.paid", invoice("sub_1"), 300)); await processStripeEvent(event("invoice.payment_failed", invoice("sub_1"), 300)); expect(status()).toBe("active"); // A newer failure still marks it past_due. await processStripeEvent(event("invoice.payment_failed", invoice("sub_1"), 400)); expect(status()).toBe("past_due"); }); test("a dispute matching no account tells admins instead of passing silently", async () => { await processStripeEvent( event("charge.dispute.created", { id: "dp_lost", payment_intent: "pi_none", amount: 900 }), ); expect(disabled).toEqual([]); expect(adminNotices.some((title) => title.includes("dp_lost"))).toBe(true); }); }); describe("BILLING_CHECKOUT_DISABLED", () => { test("blocks new checkouts while the webhook keeps processing", async () => { const { createSubscriptionCheckout, createTopupCheckout } = await import("./checkout"); const account = { accountId: "o1", kind: "org", name: "acme", canEdit: true, canView: true } as never; const viewer = { id: "u1", email: "a@b.c", isAdmin: false, emailVerified: true } as never; fakeEnv.BILLING_CHECKOUT_DISABLED = "true"; try { await expect(createSubscriptionCheckout(account, viewer)).rejects.toMatchObject({ status: 503 }); await expect(createTopupCheckout(account, viewer, "compute_10h")).rejects.toMatchObject({ status: 503 }); expect(await processStripeEvent(event("customer.subscription.created", subscription("active")))).toBe( "processed", ); expect(plan("o1")).toBe("pro"); } finally { delete fakeEnv.BILLING_CHECKOUT_DISABLED; } }); });