import { beforeEach, describe, expect, test } from "bun:test"; const { state, accountSql, fakeEnv, disabled, adminNotices, handleStripeWebhook, processStripeEvent, topupPacks, creditBalance, event, post, plan, subscription, resetWebhookHarness, } = await import("./webhook.test-helper"); beforeEach(() => resetWebhookHarness()); 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 = () => accountSql("o1") .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 = accountSql("o1") .query("SELECT account_id FROM billing_customers WHERE stripe_customer_id = 'cus_9'") .get(); expect(row).toEqual({ account_id: "o1" }); expect( state.d1.db.query("SELECT scope_id FROM scope_locators WHERE kind = 'stripe_customer' AND key = 'cus_9'").get(), ).toEqual({ scope_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 = () => ( accountSql("o1") .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"))); accountSql("u1").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)); accountSql("u1").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); accountSql("u1").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 () => { accountSql("o1").exec( "INSERT INTO billing_customers (account_id, stripe_customer_id, updated_at) VALUES ('o1', 'cus_9', 0)", ); state.d1.db.exec("INSERT INTO scope_locators (kind, key, scope_id) VALUES ('stripe_customer', 'cus_9', 'o1')"); 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("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; } }); });