[codex] guard duplicate subscription checkout (#3162)

* guard duplicate subscription checkout

* address checkout guard review feedback
This commit is contained in:
Sebastien Melki
2026-04-18 14:19:34 +03:00
committed by GitHub
parent c49c2f80f6
commit bc91c61a87
8 changed files with 562 additions and 24 deletions

View File

@@ -103,6 +103,13 @@ export default async function handler(req: Request): Promise<Response> {
const data = await resp.json();
if (!resp.ok) {
console.error('[create-checkout] Relay error:', resp.status, data);
if (resp.status === 409) {
return json({
error: data?.error || 'ACTIVE_SUBSCRIPTION_EXISTS',
message: data?.message || 'An active subscription already exists for this account.',
subscription: data?.subscription,
}, 409, cors);
}
return json({ error: data?.error || 'Checkout creation failed' }, 502, cors);
}

84
api/customer-portal.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Customer portal edge gateway.
*
* Thin auth proxy: validates Clerk bearer token, then relays to the
* Convex /relay/customer-portal HTTP action which creates a user-scoped
* Dodo customer portal session.
*/
export const config = { runtime: 'edge' };
// @ts-expect-error — JS module, no declaration file
import { getCorsHeaders } from './_cors.js';
import { validateBearerToken } from '../server/auth-session';
const CONVEX_SITE_URL =
process.env.CONVEX_SITE_URL ??
(process.env.CONVEX_URL ?? '').replace('.convex.cloud', '.convex.site');
const RELAY_SHARED_SECRET = process.env.RELAY_SHARED_SECRET ?? '';
function json(body: unknown, status: number, cors: Record<string, string>): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...cors,
},
});
}
export default async function handler(req: Request): Promise<Response> {
const cors = getCorsHeaders(req) as Record<string, string>;
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...cors,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
if (req.method !== 'POST') {
return json({ error: 'Method not allowed' }, 405, cors);
}
const authHeader = req.headers.get('Authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
if (!token) return json({ error: 'Unauthorized' }, 401, cors);
const session = await validateBearerToken(token);
if (!session.valid || !session.userId) {
return json({ error: 'Unauthorized' }, 401, cors);
}
if (!CONVEX_SITE_URL || !RELAY_SHARED_SECRET) {
return json({ error: 'Service unavailable' }, 503, cors);
}
try {
const resp = await fetch(`${CONVEX_SITE_URL}/relay/customer-portal`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${RELAY_SHARED_SECRET}`,
},
body: JSON.stringify({ userId: session.userId }),
signal: AbortSignal.timeout(15_000),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error('[customer-portal] Relay error:', resp.status, data);
return json({ error: data?.error || 'Customer portal unavailable' }, resp.status === 404 ? 404 : 502, cors);
}
return json(data, 200, cors);
} catch (err) {
console.error('[customer-portal] Relay failed:', (err as Error).message);
return json({ error: 'Customer portal unavailable' }, 502, cors);
}
}

View File

@@ -0,0 +1,172 @@
import { convexTest } from "convex-test";
import { describe, expect, test } from "vitest";
import schema from "../schema";
import { internal } from "../_generated/api";
import { PRODUCT_CATALOG } from "../config/productCatalog";
const modules = import.meta.glob("../**/*.ts");
const TEST_USER_ID = "user_billing_test_001";
const NOW = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
async function seedSubscription(
t: ReturnType<typeof convexTest>,
opts: {
planKey: string;
dodoProductId: string;
status: "active" | "on_hold" | "cancelled" | "expired";
currentPeriodEnd: number;
suffix: string;
},
) {
await t.run(async (ctx) => {
await ctx.db.insert("subscriptions", {
userId: TEST_USER_ID,
dodoSubscriptionId: `sub_billing_${opts.suffix}`,
dodoProductId: opts.dodoProductId,
planKey: opts.planKey,
status: opts.status,
currentPeriodStart: NOW - DAY_MS,
currentPeriodEnd: opts.currentPeriodEnd,
rawPayload: {},
updatedAt: NOW,
});
});
}
describe("payments billing duplicate-checkout guard", () => {
test("does not block checkout when the user has no subscriptions", async () => {
const t = convexTest(schema, modules);
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!,
},
);
expect(result).toBeNull();
});
test("blocks checkout when an active subscription exists in the same tier group", async () => {
const t = convexTest(schema, modules);
await seedSubscription(t, {
planKey: "pro_annual",
dodoProductId: PRODUCT_CATALOG.pro_annual.dodoProductId!,
status: "active",
currentPeriodEnd: NOW + 30 * DAY_MS,
suffix: "active_same_group",
});
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!,
},
);
expect(result).toMatchObject({
planKey: "pro_annual",
status: "active",
displayName: "Pro Annual",
});
});
test("blocks checkout when an on_hold subscription exists in the same tier group", async () => {
const t = convexTest(schema, modules);
await seedSubscription(t, {
planKey: "pro_monthly",
dodoProductId: PRODUCT_CATALOG.pro_monthly.dodoProductId!,
status: "on_hold",
currentPeriodEnd: NOW + 7 * DAY_MS,
suffix: "on_hold_same_group",
});
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.pro_annual.dodoProductId!,
},
);
expect(result).toMatchObject({
planKey: "pro_monthly",
status: "on_hold",
});
});
test("blocks checkout when a cancelled subscription still has time remaining", async () => {
const t = convexTest(schema, modules);
await seedSubscription(t, {
planKey: "api_starter",
dodoProductId: PRODUCT_CATALOG.api_starter.dodoProductId!,
status: "cancelled",
currentPeriodEnd: NOW + 14 * DAY_MS,
suffix: "cancelled_future",
});
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.api_starter_annual.dodoProductId!,
},
);
expect(result).toMatchObject({
planKey: "api_starter",
status: "cancelled",
});
});
test("does not block checkout when a cancelled subscription has already expired", async () => {
const t = convexTest(schema, modules);
await seedSubscription(t, {
planKey: "pro_monthly",
dodoProductId: PRODUCT_CATALOG.pro_monthly.dodoProductId!,
status: "cancelled",
currentPeriodEnd: NOW - DAY_MS,
suffix: "cancelled_past",
});
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.pro_annual.dodoProductId!,
},
);
expect(result).toBeNull();
});
test("does not block checkout for a different tier group", async () => {
const t = convexTest(schema, modules);
await seedSubscription(t, {
planKey: "api_starter",
dodoProductId: PRODUCT_CATALOG.api_starter.dodoProductId!,
status: "active",
currentPeriodEnd: NOW + 30 * DAY_MS,
suffix: "active_different_group",
});
const result = await t.query(
internal.payments.billing.getCheckoutBlockingSubscription,
{
userId: TEST_USER_ID,
productId: PRODUCT_CATALOG.pro_monthly.dodoProductId!,
},
);
expect(result).toBeNull();
});
});

View File

@@ -794,6 +794,24 @@ http.route({
referralCode: body.referralCode,
},
);
if (
result &&
typeof result === "object" &&
"blocked" in result &&
result.blocked === true
) {
return new Response(
JSON.stringify({
error: result.code,
message: result.message,
subscription: result.subscription,
}),
{
status: 409,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -808,6 +826,62 @@ http.route({
}),
});
// Service-to-service: Vercel edge gateway creates Dodo customer portal sessions.
// Authenticated via RELAY_SHARED_SECRET; edge endpoint validates Clerk JWT
// and forwards the verified userId.
http.route({
path: "/relay/customer-portal",
method: "POST",
handler: httpAction(async (ctx, request) => {
const secret = process.env.RELAY_SHARED_SECRET ?? "";
const provided = (request.headers.get("Authorization") ?? "").replace(
/^Bearer\s+/,
"",
);
if (!secret || !(await timingSafeEqualStrings(provided, secret))) {
return new Response(JSON.stringify({ error: "UNAUTHORIZED" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
let body: { userId?: string };
try {
body = await request.json() as typeof body;
} catch {
return new Response(JSON.stringify({ error: "INVALID_JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!body.userId) {
return new Response(
JSON.stringify({ error: "MISSING_FIELDS", required: ["userId"] }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
try {
const result = await ctx.runAction(
internal.payments.billing.internalGetCustomerPortalUrl,
{ userId: body.userId },
);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
const msg = err instanceof Error ? err.message : "Customer portal creation failed";
const status = msg === "No Dodo customer found for this user" ? 404 : 500;
return new Response(JSON.stringify({ error: msg }), {
status,
headers: { "Content-Type": "application/json" },
});
}
}),
});
// Resend webhook: captures bounce/complaint events and suppresses emails.
// Signature verification + internal mutation, same pattern as Dodo webhook.
http.route({

View File

@@ -10,11 +10,12 @@
*/
import { v } from "convex/values";
import { action, mutation, query, internalQuery } from "../_generated/server";
import { action, mutation, query, internalAction, internalQuery, type ActionCtx } from "../_generated/server";
import { internal } from "../_generated/api";
import { DodoPayments } from "dodopayments";
import { resolveUserId, requireUserId } from "../lib/auth";
import { getFeaturesForPlan } from "../lib/entitlements";
import { PRODUCT_CATALOG, resolveProductToPlan } from "../config/productCatalog";
// UUID v4 regex matching values produced by crypto.randomUUID() in user-identity.ts.
// Hoisted to module scope to avoid re-allocation on every claimSubscription call.
@@ -46,6 +47,41 @@ function getDodoClient(): DodoPayments {
});
}
async function createCustomerPortalUrlForUser(
ctx: Pick<ActionCtx, "runQuery">,
userId: string,
): Promise<{ portal_url: string }> {
const customer = await ctx.runQuery(
internal.payments.billing.getCustomerByUserId,
{ userId },
);
if (!customer || !customer.dodoCustomerId) {
throw new Error("No Dodo customer found for this user");
}
const client = getDodoClient();
const session = await client.customers.customerPortal.create(
customer.dodoCustomerId,
{ send_email: false },
);
return { portal_url: session.link };
}
function getSubscriptionStatusPriority(status: string): number {
switch (status) {
case "active":
return 0;
case "on_hold":
return 1;
case "cancelled":
return 2;
default:
return 3;
}
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
@@ -133,6 +169,62 @@ export const getActiveSubscription = internalQuery({
},
});
/**
* Internal query used by checkout creation to prevent duplicate subscriptions.
*
* Blocks new checkout sessions when the user already has an active/on_hold
* subscription in the same tier group, or a cancelled subscription that
* still has time remaining in the current billing period. This is an app-side
* guard only; Dodo's "Allow Multiple Subscriptions" setting is still the
* provider-side backstop for races before webhook ingestion updates Convex.
*/
export const getCheckoutBlockingSubscription = internalQuery({
args: {
userId: v.string(),
productId: v.string(),
},
handler: async (ctx, args) => {
const targetPlanKey = resolveProductToPlan(args.productId);
if (!targetPlanKey) return null;
const targetCatalogEntry = PRODUCT_CATALOG[targetPlanKey];
if (!targetCatalogEntry) return null;
const now = Date.now();
const blockingSubs = (await ctx.db
.query("subscriptions")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.collect())
.filter((sub) => {
const existingCatalogEntry = PRODUCT_CATALOG[sub.planKey];
if (!existingCatalogEntry) return false;
if (existingCatalogEntry.tierGroup !== targetCatalogEntry.tierGroup) return false;
if (sub.status === "active" || sub.status === "on_hold") return true;
return sub.status === "cancelled" && sub.currentPeriodEnd > now;
})
.sort((a, b) => {
const pa = getSubscriptionStatusPriority(a.status);
const pb = getSubscriptionStatusPriority(b.status);
if (pa !== pb) return pa - pb;
if (a.currentPeriodEnd !== b.currentPeriodEnd) {
return b.currentPeriodEnd - a.currentPeriodEnd;
}
return b.updatedAt - a.updatedAt;
});
const blocking = blockingSubs[0];
if (!blocking) return null;
return {
planKey: blocking.planKey,
displayName: PRODUCT_CATALOG[blocking.planKey]?.displayName ?? blocking.planKey,
status: blocking.status,
currentPeriodEnd: blocking.currentPeriodEnd,
dodoSubscriptionId: blocking.dodoSubscriptionId,
};
},
});
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
@@ -146,23 +238,21 @@ export const getCustomerPortalUrl = action({
args: {},
handler: async (ctx, _args) => {
const userId = await requireUserId(ctx);
return createCustomerPortalUrlForUser(ctx, userId);
},
});
const customer = await ctx.runQuery(
internal.payments.billing.getCustomerByUserId,
{ userId },
);
if (!customer || !customer.dodoCustomerId) {
throw new Error("No Dodo customer found for this user");
/**
* Internal action callable from the edge gateway to create a user-scoped
* Dodo Customer Portal session after the Clerk JWT has been verified there.
*/
export const internalGetCustomerPortalUrl = internalAction({
args: { userId: v.string() },
handler: async (ctx, args) => {
if (!args.userId) {
throw new Error("userId is required");
}
const client = getDodoClient();
const session = await client.customers.customerPortal.create(
customer.dodoCustomerId,
{ send_email: false },
);
return { portal_url: session.link };
return createCustomerPortalUrlForUser(ctx, args.userId);
},
});

View File

@@ -11,10 +11,13 @@
import { v, ConvexError } from "convex/values";
import { action, internalAction, type ActionCtx } from "../_generated/server";
import { internal } from "../_generated/api";
import { checkout } from "../lib/dodo";
import { requireUserId, resolveUserIdentity } from "../lib/auth";
import { signUserId } from "../lib/identitySigning";
const ACTIVE_SUBSCRIPTION_EXISTS = "ACTIVE_SUBSCRIPTION_EXISTS";
// ---------------------------------------------------------------------------
// Shared checkout session creation logic
// ---------------------------------------------------------------------------
@@ -32,6 +35,60 @@ interface UserInfo {
name?: string;
}
interface BlockingSubscriptionInfo {
planKey: string;
displayName: string;
status: "active" | "on_hold" | "cancelled";
currentPeriodEnd: number;
dodoSubscriptionId: string;
}
function buildBlockedCheckoutPayload(
subscription: BlockingSubscriptionInfo,
){
return {
code: ACTIVE_SUBSCRIPTION_EXISTS,
message: `A ${subscription.displayName} subscription already exists for this account. Use Manage Billing to update it instead of purchasing again.`,
subscription: {
planKey: subscription.planKey,
displayName: subscription.displayName,
status: subscription.status,
currentPeriodEnd: subscription.currentPeriodEnd,
dodoSubscriptionId: subscription.dodoSubscriptionId,
},
};
}
function buildBlockedCheckoutResponse(
subscription: BlockingSubscriptionInfo,
){
return {
blocked: true,
...buildBlockedCheckoutPayload(subscription),
};
}
async function getCheckoutBlockingSubscription(
ctx: ActionCtx,
userId: string,
productId: string,
): Promise<BlockingSubscriptionInfo | null> {
const result = await ctx.runQuery(
internal.payments.billing.getCheckoutBlockingSubscription,
{ userId, productId },
);
if (!result || result.status === "expired") {
return null;
}
return {
planKey: result.planKey,
displayName: result.displayName,
status: result.status,
currentPeriodEnd: result.currentPeriodEnd,
dodoSubscriptionId: result.dodoSubscriptionId,
};
}
async function _createCheckoutSession(
ctx: ActionCtx,
args: CheckoutArgs,
@@ -116,6 +173,10 @@ export const createCheckout = action({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const identity = await resolveUserIdentity(ctx);
const blocking = await getCheckoutBlockingSubscription(ctx, userId, args.productId);
if (blocking) {
throw new ConvexError(buildBlockedCheckoutPayload(blocking));
}
const customerName = identity
? [identity.givenName, identity.familyName].filter(Boolean).join(" ") ||
@@ -148,6 +209,10 @@ export const internalCreateCheckout = internalAction({
if (!args.userId) {
throw new ConvexError("userId is required");
}
const blocking = await getCheckoutBlockingSubscription(ctx, args.userId, args.productId);
if (blocking) {
return buildBlockedCheckoutResponse(blocking);
}
return _createCheckoutSession(
ctx,
{

View File

@@ -9,6 +9,8 @@ import type { Clerk } from '@clerk/clerk-js';
import type { CheckoutEvent } from 'dodopayments-checkout';
const API_BASE = 'https://api.worldmonitor.app/api';
const DODO_PORTAL_FALLBACK_URL = 'https://customer.dodopayments.com';
const ACTIVE_SUBSCRIPTION_EXISTS = 'ACTIVE_SUBSCRIPTION_EXISTS';
const MONO_FONT = "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace";
@@ -114,14 +116,7 @@ async function doCheckout(
checkoutInFlight = true;
try {
// Get Clerk token with retry
let token = await clerk?.session?.getToken({ template: 'convex' }).catch(() => null)
?? await clerk?.session?.getToken().catch(() => null);
if (!token) {
await new Promise((r) => setTimeout(r, 2000));
token = await clerk?.session?.getToken({ template: 'convex' }).catch(() => null)
?? await clerk?.session?.getToken().catch(() => null);
}
const token = await getAuthToken();
if (!token) {
console.error('[checkout] No auth token after retry');
return false;
@@ -145,6 +140,9 @@ async function doCheckout(
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error('[checkout] Edge error:', resp.status, err);
if (resp.status === 409 && err?.error === ACTIVE_SUBSCRIPTION_EXISTS) {
await openBillingPortal(token, err?.message);
}
return false;
}
@@ -193,3 +191,44 @@ async function doCheckout(
checkoutInFlight = false;
}
}
async function getAuthToken(): Promise<string | null> {
let token = await clerk?.session?.getToken({ template: 'convex' }).catch(() => null)
?? await clerk?.session?.getToken().catch(() => null);
if (!token) {
await new Promise((r) => setTimeout(r, 2000));
token = await clerk?.session?.getToken({ template: 'convex' }).catch(() => null)
?? await clerk?.session?.getToken().catch(() => null);
}
return token;
}
async function openBillingPortal(token: string, message?: string): Promise<void> {
if (message) {
console.warn('[checkout] Redirecting to billing portal:', message);
}
try {
const resp = await fetch(`${API_BASE}/customer-portal`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(15_000),
});
const result = await resp.json().catch(() => ({}));
const url = typeof result?.portal_url === 'string'
? result.portal_url
: DODO_PORTAL_FALLBACK_URL;
if (!resp.ok) {
console.error('[checkout] Customer portal error:', resp.status, result);
}
window.location.assign(url);
} catch (err) {
console.error('[checkout] Failed to open billing portal:', err);
window.location.assign(DODO_PORTAL_FALLBACK_URL);
}
}

View File

@@ -14,6 +14,7 @@
import * as Sentry from '@sentry/browser';
import { DodoPayments } from 'dodopayments-checkout';
import type { CheckoutEvent } from 'dodopayments-checkout';
import { openBillingPortal } from './billing';
import { getCurrentClerkUser, getClerkToken } from './clerk';
const CHECKOUT_PRODUCT_PARAM = 'checkoutProduct';
@@ -22,6 +23,7 @@ const CHECKOUT_DISCOUNT_PARAM = 'checkoutDiscount';
const PENDING_CHECKOUT_KEY = 'wm-pending-checkout';
const POST_CHECKOUT_FLAG_KEY = 'wm-post-checkout';
const APP_CHECKOUT_BASE_URL = 'https://worldmonitor.app/';
const ACTIVE_SUBSCRIPTION_EXISTS = 'ACTIVE_SUBSCRIPTION_EXISTS';
/**
* Session flag set just before the post-overlay reload. Lets panel-layout
@@ -297,6 +299,11 @@ export async function startCheckout(
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error('[checkout] Edge endpoint error:', resp.status, err);
if (resp.status === 409 && err?.error === ACTIVE_SUBSCRIPTION_EXISTS) {
clearPendingCheckoutIntent();
await openBillingPortal();
return false;
}
if (fallbackToPricingPage) window.open('https://worldmonitor.app/pro', '_blank');
return false;
}