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 { 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, 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(); d1.db.exec("INSERT INTO accounts 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("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 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; } }); });