mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
[codex] guard duplicate subscription checkout (#3162)
* guard duplicate subscription checkout * address checkout guard review feedback
This commit is contained in:
@@ -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
84
api/customer-portal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
172
convex/__tests__/billing.test.ts
Normal file
172
convex/__tests__/billing.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user