Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/test/stripe-account-presence.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
16 changes: 16 additions & 0 deletions supabase/functions/stripe-webhook/account-presence.ts
Original file line number Diff line number Diff line change
@@ -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";
}
36 changes: 36 additions & 0 deletions supabase/functions/stripe-webhook/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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<AccountPresence> {
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<void> {
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;
Expand Down
Loading