From dbf53717db12c8f10628a502a481b926e03f65dd Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 5 Apr 2026 23:00:12 +0100 Subject: [PATCH] feat: create worspace on checkout.session.completed --- .../api/stripe/create_checkout_session.ts | 206 ++++++++++-------- apps/web/src/pages/api/stripe/webhook.ts | 38 +++- 2 files changed, 150 insertions(+), 94 deletions(-) diff --git a/apps/web/src/pages/api/stripe/create_checkout_session.ts b/apps/web/src/pages/api/stripe/create_checkout_session.ts index 6be70a66..4d0f655c 100644 --- a/apps/web/src/pages/api/stripe/create_checkout_session.ts +++ b/apps/web/src/pages/api/stripe/create_checkout_session.ts @@ -3,10 +3,13 @@ import { env } from "next-runtime-env"; import { z } from "zod"; import { createNextApiContext } from "@kan/api/trpc"; +import { withApiLogging } from "@kan/api/utils/apiLogging"; +import { assertPermission } from "@kan/api/utils/permissions"; +import { withRateLimit } from "@kan/api/utils/rateLimit"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; +import { generateUID } from "@kan/shared/utils"; import { createStripeClient } from "@kan/stripe"; -import { withRateLimit } from "@kan/api/utils/rateLimit"; const workspaceSlugSchema = z .string() @@ -17,103 +20,130 @@ const workspaceSlugSchema = z interface CheckoutSessionRequest { successUrl: string; cancelUrl: string; - slug: string; - workspacePublicId: string; - stripeCustomerId: string; + billing?: string; + workspacePublicId?: string; + slug?: string; + workspaceName?: string; + workspaceDescription?: string; + workspaceSlug?: string; + plan?: string; } export default withRateLimit( { points: 100, duration: 60 }, - async (req: NextApiRequest, res: NextApiResponse) => { - const stripe = createStripeClient(); + withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => { + const stripe = createStripeClient(); - if (req.method !== "POST") { - return res.status(405).json({ error: "Method not allowed" }); - } - - try { - const { user, db } = await createNextApiContext(req); - - if (!user) { - return res.status(404).json({ error: "User not found" }); + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); } - const body = req.body as CheckoutSessionRequest; - const { successUrl, cancelUrl, slug, workspacePublicId } = body; + try { + const { user, db } = await createNextApiContext(req); - if (!successUrl || !cancelUrl || !workspacePublicId) { - return res.status(400).json({ error: "Missing required fields" }); - } - - if (slug) { - const slugResult = workspaceSlugSchema.safeParse(slug); - - if (!slugResult.success) { - return new Response( - JSON.stringify({ error: "Invalid workspace slug" }), - { - status: 400, - headers: { "Content-Type": "application/json" }, - }, - ); + if (!user) { + return res.status(404).json({ error: "User not found" }); } - } - const workspace = await workspaceRepo.getAllByUserId(db, user.id); - - const isMemberOfWorkspace = workspace.some( - ({ workspace }) => workspace.publicId === body.workspacePublicId, - ); - - if (!isMemberOfWorkspace) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - const subscription = await subscriptionRepo.create(db, { - plan: "pro", - referenceId: workspacePublicId, - userId: user.id, - stripeCustomerId: user.stripeCustomerId ?? "", - status: "incomplete", - }); - - const subscriptionId = subscription?.id; - - if (!subscriptionId) { - return res.status(500).json({ error: "Error creating subscription" }); - } - - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - payment_method_collection: "always", - line_items: [ - { - price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID, - quantity: 1, - }, - ], - subscription_data: { - trial_period_days: 14, - }, - success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, - cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, - client_reference_id: workspacePublicId, - customer: user.stripeCustomerId ?? undefined, - metadata: { - ...(slug && { workspaceSlug: slug }), + const body = req.body as CheckoutSessionRequest; + const { + successUrl, + cancelUrl, + billing, workspacePublicId, - userId: user.id, - subscriptionId, - }, - }); + slug, + workspaceName, + workspaceDescription, + workspaceSlug, + } = body; - return res.status(200).json({ url: session.url }); - } catch (error) { - console.error("Error:", error); - return res.status(500).json({ error: "Error creating checkout session" }); - } - }, + if (!successUrl || !cancelUrl) { + return res.status(400).json({ error: "Missing required fields" }); + } + + if (!workspacePublicId && !workspaceName) { + return res + .status(400) + .json({ error: "Must provide workspacePublicId or workspaceName" }); + } + + const resolvedSlug = slug ?? workspaceSlug; + + if (resolvedSlug) { + const slugResult = workspaceSlugSchema.safeParse(resolvedSlug); + if (!slugResult.success) { + return res.status(400).json({ error: "Invalid workspace slug" }); + } + } + + let resolvedWorkspacePublicId = workspacePublicId; + let subscriptionId: number | undefined; + + if (workspacePublicId) { + // Existing workspace upgrade + const workspace = await workspaceRepo.getByPublicId( + db, + workspacePublicId, + ); + + if (!workspace) { + return res.status(404).json({ error: "Workspace not found" }); + } + + try { + await assertPermission(db, user.id, workspace.id, "workspace:manage"); + } catch { + return res.status(403).json({ error: "Unauthorized" }); + } + + const subscription = await subscriptionRepo.create(db, { + plan: "pro", + referenceId: workspacePublicId, + userId: user.id, + stripeCustomerId: user.stripeCustomerId ?? "", + status: "incomplete", + }); + + subscriptionId = subscription?.id; + + if (!subscriptionId) { + return res.status(500).json({ error: "Error creating subscription" }); + } + } else { + resolvedWorkspacePublicId = generateUID(); + } + + const priceId = + billing === "annual" + ? (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ?? + process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID) + : process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID; + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + payment_method_collection: "always", + line_items: [{ price: priceId, quantity: 1 }], + subscription_data: { trial_period_days: 14 }, + success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, + cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, + client_reference_id: resolvedWorkspacePublicId, + customer: user.stripeCustomerId ?? undefined, + metadata: { + workspacePublicId: resolvedWorkspacePublicId!, + userId: user.id, + userEmail: user.email ?? "", + ...(resolvedSlug && { workspaceSlug: resolvedSlug }), + ...(workspaceName && { workspaceName }), + ...(workspaceDescription && { workspaceDescription }), + ...(subscriptionId && { subscriptionId: String(subscriptionId) }), + plan: body.plan ?? "pro", + isNewWorkspace: workspaceName ? "true" : "false", + }, + }); + + return res.status(200).json({ url: session.url }); + } catch (error) { + return res.status(500).json({ error: "Error creating checkout session" }); + } + }), ); diff --git a/apps/web/src/pages/api/stripe/webhook.ts b/apps/web/src/pages/api/stripe/webhook.ts index 842f4991..64351cc0 100644 --- a/apps/web/src/pages/api/stripe/webhook.ts +++ b/apps/web/src/pages/api/stripe/webhook.ts @@ -44,20 +44,46 @@ export default async function handler( const { db } = await createNextApiContext(req); - log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received"); + log.info( + { eventType: event.type, eventId: event.id }, + "Stripe webhook received", + ); switch (event.type) { case "checkout.session.completed": { const checkoutSession = event.data.object; + const meta = checkoutSession.metadata; - const metaData = checkoutSession.metadata; + if (!meta?.workspacePublicId) break; - if (metaData?.workspacePublicId) { - await workspaceRepo.update(db, metaData.workspacePublicId, { - ...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }), - plan: "pro", + if ( + meta.isNewWorkspace === "true" && + meta.workspaceName && + meta.userId && + meta.userEmail + ) { + const slug = meta.workspaceSlug ?? meta.workspacePublicId; + + await workspaceRepo.create(db, { + publicId: meta.workspacePublicId, + name: meta.workspaceName, + slug, + createdBy: meta.userId, + createdByEmail: meta.userEmail, + ...(meta.workspaceDescription && { + description: meta.workspaceDescription, + }), }); } + + const plan = meta.plan === "team" ? "team" : "pro"; + await workspaceRepo.update(db, meta.workspacePublicId, { + plan, + ...(plan === "pro" && + meta.workspaceSlug && + meta.isNewWorkspace !== "true" && { slug: meta.workspaceSlug }), + }); + break; } default: