Skip to content

Commit 739cbb7

Browse files
Refuse Checkout when a subscription can still bill
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 <Programcode@users.noreply.github.com>
1 parent 4f9e0a0 commit 739cbb7

3 files changed

Lines changed: 44 additions & 0 deletions

File tree

‎src/test/checkout-security.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
findCustomerOwnedByUser,
4+
hasOpenSubscription,
45
isAllowedCheckoutRedirectUrl,
56
} from "../../supabase/functions/create-checkout/checkout-security";
67

@@ -28,4 +29,15 @@ describe("checkout security", () => {
2829
expect(findCustomerOwnedByUser(customers, "user-123")).toBe("cus_correct");
2930
expect(findCustomerOwnedByUser(customers, "missing-user")).toBeNull();
3031
});
32+
33+
it("treats a subscription that can still bill as a reason to refuse another checkout", () => {
34+
expect(hasOpenSubscription([{ status: "active" }, { status: "canceled" }])).toBe(true);
35+
expect(hasOpenSubscription([{ status: "trialing" }])).toBe(true);
36+
expect(hasOpenSubscription([{ status: "past_due" }])).toBe(true);
37+
expect(hasOpenSubscription([{ status: "paused" }])).toBe(true);
38+
expect(hasOpenSubscription([{ status: "unpaid" }])).toBe(true);
39+
expect(hasOpenSubscription([{ status: "canceled" }, { status: "incomplete" }, { status: "incomplete_expired" }])).toBe(false);
40+
expect(hasOpenSubscription([])).toBe(false);
41+
expect(hasOpenSubscription(null)).toBe(false);
42+
});
3143
});

‎supabase/functions/create-checkout/checkout-security.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export function isAllowedCheckoutRedirectUrl(value: string): boolean {
2424
}
2525
}
2626

27+
const OPEN_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid", "paused"]);
28+
29+
/** True when this user already has a Stripe subscription that can still bill them. */
30+
export function hasOpenSubscription(rows: Array<{ status?: string | null }> | null | undefined): boolean {
31+
return (rows ?? []).some((row) => typeof row.status === "string" && OPEN_SUBSCRIPTION_STATUSES.has(row.status));
32+
}
33+
2734
export function findCustomerOwnedByUser(
2835
customers: StripeCustomerLike[],
2936
userId: string,

‎supabase/functions/create-checkout/index.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { HttpError, jsonError, requireUser } from "../_shared/auth.ts";
77
import { getCorsHeaders } from "../_shared/cors.ts";
88
import {
99
findCustomerOwnedByUser,
10+
hasOpenSubscription,
1011
isAllowedCheckoutRedirectUrl,
1112
} from "./checkout-security.ts";
1213

@@ -249,6 +250,30 @@ export async function handleRequest(req: Request): Promise<Response> {
249250

250251
const resolvedPrice = await resolveCheckoutPriceId();
251252

253+
// A second Checkout session creates a second subscription on the same
254+
// customer. Refuse while one can still bill, and send the student to
255+
// Settings to manage the one they have.
256+
const { data: existingSubscriptions, error: subscriptionReadError } = await supabase
257+
.from("subscriptions")
258+
.select("status")
259+
.eq("user_id", user.id);
260+
if (subscriptionReadError) {
261+
console.error(`[CHECKOUT] Could not read subscriptions for ${user.id}: ${subscriptionReadError.code}`);
262+
return new Response(JSON.stringify({ error: "Failed to create checkout session" }), {
263+
status: 500,
264+
headers: { ...corsHeaders, "Content-Type": "application/json" },
265+
});
266+
}
267+
if (hasOpenSubscription(existingSubscriptions)) {
268+
return new Response(JSON.stringify({
269+
error: "You already have a subscription. Manage it from Settings.",
270+
code: "already_subscribed",
271+
}), {
272+
status: 409,
273+
headers: { ...corsHeaders, "Content-Type": "application/json" },
274+
});
275+
}
276+
252277
// Get or create Stripe customer
253278
let customerId = await findCustomerIdByUser({ id: user.id, email: user.email });
254279
if (!customerId) {

0 commit comments

Comments
 (0)