From c3c07d8c9a83bd0943e810cec32e91f44a7faddd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:54:02 +0000 Subject: [PATCH] Cancel a subscription that completes after the account is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an account cancels the billing it can see. A Checkout that finishes in that window still created a Stripe subscription, then the webhook failed forever because the user no longer existed, so Stripe kept charging. The webhook now cancels that subscription and acknowledges the event. A lookup that fails for any other reason still retries. Co-authored-by: Gáborjáni-Szabó Ádám --- src/test/stripe-account-presence.test.ts | 18 ++++++++++ .../stripe-webhook/account-presence.ts | 16 +++++++++ supabase/functions/stripe-webhook/index.ts | 36 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/test/stripe-account-presence.test.ts create mode 100644 supabase/functions/stripe-webhook/account-presence.ts diff --git a/src/test/stripe-account-presence.test.ts b/src/test/stripe-account-presence.test.ts new file mode 100644 index 000000000..341e2e7cb --- /dev/null +++ b/src/test/stripe-account-presence.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { accountPresence } from "../../supabase/functions/stripe-webhook/account-presence"; + +describe("checkout after account deletion", () => { + it("keeps a checkout whose account is still there", () => { + expect(accountPresence({ user: { id: "user-1" } })).toBe("present"); + }); + + it("treats a deleted account as missing so billing can be canceled", () => { + expect(accountPresence({ user: null, errorStatus: 404, errorMessage: "User not found" })).toBe("missing"); + expect(accountPresence({ user: null, errorMessage: "User not found" })).toBe("missing"); + }); + + it("retries when the account lookup itself failed", () => { + expect(accountPresence({ user: null, errorStatus: 500, errorMessage: "timeout" })).toBe("unknown"); + expect(accountPresence({ user: null })).toBe("unknown"); + }); +}); diff --git a/supabase/functions/stripe-webhook/account-presence.ts b/supabase/functions/stripe-webhook/account-presence.ts new file mode 100644 index 000000000..28a0147fc --- /dev/null +++ b/supabase/functions/stripe-webhook/account-presence.ts @@ -0,0 +1,16 @@ +export type AccountPresence = "present" | "missing" | "unknown"; + +/** + * A checkout can finish after the account is gone. Only an explicit + * not-found is safe to treat as deleted; anything else should retry. + */ +export function accountPresence(lookup: { + user: unknown; + errorStatus?: number | null; + errorMessage?: string | null; +}): AccountPresence { + if (lookup.user) return "present"; + const message = lookup.errorMessage ?? ""; + if (lookup.errorStatus === 404 || /not found/i.test(message)) return "missing"; + return "unknown"; +} diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index a920e31e6..2f910cd2e 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -8,6 +8,7 @@ import { processWebhookEvent, webhookFailureResponse, } from "./webhook-event-lifecycle.ts"; +import { accountPresence, type AccountPresence } from "./account-presence.ts"; // Validate required environment variables const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY"); @@ -175,6 +176,19 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { console.log(`[WEBHOOK] Checkout completed for user ${userId}, sub: ${subscriptionId}`); + // Deleting an account cancels billing it can see. A Checkout that finishes + // in that window would otherwise keep charging a subscription with no user, + // and the database insert fails forever. Cancel it and acknowledge the event. + const presence = await lookupAccount(userId); + if (presence === "unknown") { + throw new Error("Could not confirm the account for this checkout"); + } + if (presence === "missing") { + console.warn(`[WEBHOOK] Checkout completed after the account was deleted; canceling ${subscriptionId}`); + await cancelSubscriptionIfPresent(subscriptionId); + return; + } + // Fetch the full subscription from Stripe const subscription = await stripe.subscriptions.retrieve(subscriptionId); @@ -217,6 +231,28 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { console.log(`[WEBHOOK] User ${userId} upgraded to Pro`); } +async function lookupAccount(userId: string): Promise { + const { data, error } = await supabase.auth.admin.getUserById(userId); + const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" + ? error.status + : null; + return accountPresence({ + user: data?.user ?? null, + errorStatus: status, + errorMessage: error?.message ?? null, + }); +} + +async function cancelSubscriptionIfPresent(subscriptionId: string): Promise { + try { + await stripe.subscriptions.cancel(subscriptionId); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + if (code === "resource_missing") return; + throw error; + } +} + async function handlePaymentSucceeded(invoice: Stripe.Invoice) { const subscriptionId = invoice.subscription as string; if (!subscriptionId) return;