From 739cbb7567ecf952c85cf1deeea976c17e430565 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:53:07 +0000 Subject: [PATCH] Refuse Checkout when a subscription can still bill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A student who already had Pro could open Checkout again and start a second Stripe subscription on the same customer. Checkout now stops when a subscription is active, trialing, past due, unpaid, or paused, and tells them to manage it from Settings. Co-authored-by: Gáborjáni-Szabó Ádám --- src/test/checkout-security.test.ts | 12 +++++++++ .../create-checkout/checkout-security.ts | 7 ++++++ supabase/functions/create-checkout/index.ts | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/src/test/checkout-security.test.ts b/src/test/checkout-security.test.ts index 52966d15f..cd3ca437e 100644 --- a/src/test/checkout-security.test.ts +++ b/src/test/checkout-security.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { findCustomerOwnedByUser, + hasOpenSubscription, isAllowedCheckoutRedirectUrl, } from "../../supabase/functions/create-checkout/checkout-security"; @@ -28,4 +29,15 @@ describe("checkout security", () => { expect(findCustomerOwnedByUser(customers, "user-123")).toBe("cus_correct"); expect(findCustomerOwnedByUser(customers, "missing-user")).toBeNull(); }); + + it("treats a subscription that can still bill as a reason to refuse another checkout", () => { + expect(hasOpenSubscription([{ status: "active" }, { status: "canceled" }])).toBe(true); + expect(hasOpenSubscription([{ status: "trialing" }])).toBe(true); + expect(hasOpenSubscription([{ status: "past_due" }])).toBe(true); + expect(hasOpenSubscription([{ status: "paused" }])).toBe(true); + expect(hasOpenSubscription([{ status: "unpaid" }])).toBe(true); + expect(hasOpenSubscription([{ status: "canceled" }, { status: "incomplete" }, { status: "incomplete_expired" }])).toBe(false); + expect(hasOpenSubscription([])).toBe(false); + expect(hasOpenSubscription(null)).toBe(false); + }); }); diff --git a/supabase/functions/create-checkout/checkout-security.ts b/supabase/functions/create-checkout/checkout-security.ts index f5ad57e67..42509a2ed 100644 --- a/supabase/functions/create-checkout/checkout-security.ts +++ b/supabase/functions/create-checkout/checkout-security.ts @@ -24,6 +24,13 @@ export function isAllowedCheckoutRedirectUrl(value: string): boolean { } } +const OPEN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid", "paused"]); + +/** True when this user already has a Stripe subscription that can still bill them. */ +export function hasOpenSubscription(rows: Array<{ status?: string | null }> | null | undefined): boolean { + return (rows ?? []).some((row) => typeof row.status === "string" && OPEN_SUBSCRIPTION_STATUSES.has(row.status)); +} + export function findCustomerOwnedByUser( customers: StripeCustomerLike[], userId: string, diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index 1d54f8a67..c46b018fd 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -7,6 +7,7 @@ import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { findCustomerOwnedByUser, + hasOpenSubscription, isAllowedCheckoutRedirectUrl, } from "./checkout-security.ts"; @@ -249,6 +250,30 @@ export async function handleRequest(req: Request): Promise { const resolvedPrice = await resolveCheckoutPriceId(); + // A second Checkout session creates a second subscription on the same + // customer. Refuse while one can still bill, and send the student to + // Settings to manage the one they have. + const { data: existingSubscriptions, error: subscriptionReadError } = await supabase + .from("subscriptions") + .select("status") + .eq("user_id", user.id); + if (subscriptionReadError) { + console.error(`[CHECKOUT] Could not read subscriptions for ${user.id}: ${subscriptionReadError.code}`); + return new Response(JSON.stringify({ error: "Failed to create checkout session" }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + if (hasOpenSubscription(existingSubscriptions)) { + return new Response(JSON.stringify({ + error: "You already have a subscription. Manage it from Settings.", + code: "already_subscribed", + }), { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + // Get or create Stripe customer let customerId = await findCustomerIdByUser({ id: user.id, email: user.email }); if (!customerId) {