From b8adfd86dfa3f8152d6282c84646aeb2b86047cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 10:53:35 +0000 Subject: [PATCH] Refuse a second Pro checkout while a paid period is still open. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create-checkout never looked for an active or trialing subscription, so a paying student could buy Pro again. A future period now returns 409, and a failed lookup fails closed instead of opening Stripe. Co-authored-by: Gáborjáni-Szabó Ádám --- .../functions/create-checkout/auth_test.ts | 62 ++++++++++++++++++- supabase/functions/create-checkout/index.ts | 48 ++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/supabase/functions/create-checkout/auth_test.ts b/supabase/functions/create-checkout/auth_test.ts index 4dd1ca354..730c440b2 100644 --- a/supabase/functions/create-checkout/auth_test.ts +++ b/supabase/functions/create-checkout/auth_test.ts @@ -13,7 +13,7 @@ for (const [key, value] of Object.entries({ STRIPE_SECRET_KEY: "sk_test_not_used", })) Deno.env.set(key, value); -const { handleRequest } = await import("./index.ts"); +const { handleRequest, subscriptionStillPaid } = await import("./index.ts"); const send = (authorization?: string) => new Request("http://127.0.0.1/functions/v1/create-checkout", { @@ -38,3 +38,63 @@ Deno.test("create-checkout lets a valid token through to request validation", as authCalls: 1, }]); }); + +Deno.test("subscriptionStillPaid blocks only an active or trialing period that has not ended", () => { + const future = "2099-01-01T00:00:00.000Z"; + const past = "2000-01-01T00:00:00.000Z"; + const now = Date.parse("2026-10-22T00:00:00.000Z"); + if (!subscriptionStillPaid({ status: "active", current_period_end: future }, now)) throw new Error("active future"); + if (!subscriptionStillPaid({ status: "trialing", current_period_end: future }, now)) throw new Error("trialing future"); + if (subscriptionStillPaid({ status: "active", current_period_end: past }, now)) throw new Error("active past"); + if (subscriptionStillPaid({ status: "canceled", current_period_end: future }, now)) throw new Error("canceled"); + if (subscriptionStillPaid({ status: "past_due", current_period_end: future }, now)) throw new Error("past_due"); + if (subscriptionStillPaid(null, now)) throw new Error("missing row"); +}); + +const sendCheckout = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/create-checkout", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization === undefined ? {} : { Authorization: authorization }), + }, + body: JSON.stringify({ + successUrl: "https://debate.pi-stack.org/app/settings?checkout=success", + cancelUrl: "https://debate.pi-stack.org/app/settings?checkout=canceled", + }), + }); + +Deno.test("create-checkout refuses a second purchase while Pro is still paid", async () => { + const calls: string[] = []; + await runBearerCases(handleRequest, sendCheckout, [{ + label: "already subscribed", + authorization: `Bearer ${VALID_TOKEN}`, + status: 409, + body: { error: "You already have an active Pro subscription.", code: "ALREADY_SUBSCRIBED" }, + authCalls: 1, + }], (url) => { + calls.push(url.pathname); + if (url.pathname.endsWith("/rest/v1/subscriptions")) { + return Response.json({ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }); + } + throw new Error(`Unexpected request: ${url}`); + }); + if (calls.some((path) => path.includes("stripe") || path.includes("checkout"))) { + throw new Error(`Stripe was contacted: ${calls.join(", ")}`); + } +}); + +Deno.test("create-checkout fails closed when the subscription lookup errors", async () => { + await runBearerCases(handleRequest, sendCheckout, [{ + label: "lookup failed", + authorization: `Bearer ${VALID_TOKEN}`, + status: 503, + body: { error: "Could not confirm your subscription. Try again in a moment." }, + authCalls: 1, + }], (url) => { + if (url.pathname.endsWith("/rest/v1/subscriptions")) { + return Response.json({ message: "db down" }, { status: 500 }); + } + throw new Error(`Unexpected request: ${url}`); + }); +}); diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index 1d54f8a67..93c2558a2 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -122,6 +122,35 @@ async function resolveCheckoutPriceId(): Promise<{ id: string; source: string }> ); } +const BLOCKING_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); + +/** A paid period that has not ended yet. Past or missing end dates do not block a new checkout. */ +export function subscriptionStillPaid( + row: { status?: string | null; current_period_end?: string | null } | null, + now = Date.now(), +): boolean { + if (!row?.status || !BLOCKING_SUBSCRIPTION_STATUSES.has(row.status)) return false; + const end = Date.parse(row.current_period_end ?? ""); + return Number.isFinite(end) && end > now; +} + +async function findBlockingSubscription(userId: string): Promise<"subscribed" | "clear" | "lookup_failed"> { + const { data, error } = await supabase + .from("subscriptions") + .select("status, current_period_end") + .eq("user_id", userId) + .in("status", ["active", "trialing"]) + .order("current_period_end", { ascending: false }) + .limit(1) + .maybeSingle(); + + if (error) { + console.error(`[CHECKOUT] Active subscription lookup failed for ${userId}: ${error.code ?? "unknown"}`); + return "lookup_failed"; + } + return subscriptionStillPaid(data) ? "subscribed" : "clear"; +} + async function findCustomerIdByUser(user: { id: string; email?: string | null }): Promise { const { data: existingSub, error: subLookupError } = await supabase .from("subscriptions") @@ -247,6 +276,25 @@ export async function handleRequest(req: Request): Promise { }); } + const alreadySubscribed = await findBlockingSubscription(user.id); + if (alreadySubscribed === "lookup_failed") { + return new Response(JSON.stringify({ + error: "Could not confirm your subscription. Try again in a moment.", + }), { + status: 503, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + if (alreadySubscribed === "subscribed") { + return new Response(JSON.stringify({ + error: "You already have an active Pro subscription.", + code: "ALREADY_SUBSCRIBED", + }), { + status: 409, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const resolvedPrice = await resolveCheckoutPriceId(); // Get or create Stripe customer