feat: create worspace on checkout.session.completed

This commit is contained in:
Henry
2026-04-05 23:00:12 +01:00
parent 0dc5092ebc
commit dbf53717db
2 changed files with 150 additions and 94 deletions

View File

@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
import { z } from "zod"; import { z } from "zod";
import { createNextApiContext } from "@kan/api/trpc"; 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 subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z const workspaceSlugSchema = z
.string() .string()
@@ -17,14 +20,18 @@ const workspaceSlugSchema = z
interface CheckoutSessionRequest { interface CheckoutSessionRequest {
successUrl: string; successUrl: string;
cancelUrl: string; cancelUrl: string;
slug: string; billing?: string;
workspacePublicId: string; workspacePublicId?: string;
stripeCustomerId: string; slug?: string;
workspaceName?: string;
workspaceDescription?: string;
workspaceSlug?: string;
plan?: string;
} }
export default withRateLimit( export default withRateLimit(
{ points: 100, duration: 60 }, { points: 100, duration: 60 },
async (req: NextApiRequest, res: NextApiResponse) => { withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
const stripe = createStripeClient(); const stripe = createStripeClient();
if (req.method !== "POST") { if (req.method !== "POST") {
@@ -39,37 +46,54 @@ export default withRateLimit(
} }
const body = req.body as CheckoutSessionRequest; const body = req.body as CheckoutSessionRequest;
const { successUrl, cancelUrl, slug, workspacePublicId } = body; const {
successUrl,
cancelUrl,
billing,
workspacePublicId,
slug,
workspaceName,
workspaceDescription,
workspaceSlug,
} = body;
if (!successUrl || !cancelUrl || !workspacePublicId) { if (!successUrl || !cancelUrl) {
return res.status(400).json({ error: "Missing required fields" }); return res.status(400).json({ error: "Missing required fields" });
} }
if (slug) { if (!workspacePublicId && !workspaceName) {
const slugResult = workspaceSlugSchema.safeParse(slug); 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) { if (!slugResult.success) {
return new Response( return res.status(400).json({ error: "Invalid workspace slug" });
JSON.stringify({ error: "Invalid workspace slug" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
} }
} }
const workspace = await workspaceRepo.getAllByUserId(db, user.id); let resolvedWorkspacePublicId = workspacePublicId;
let subscriptionId: number | undefined;
const isMemberOfWorkspace = workspace.some( if (workspacePublicId) {
({ workspace }) => workspace.publicId === body.workspacePublicId, // Existing workspace upgrade
const workspace = await workspaceRepo.getByPublicId(
db,
workspacePublicId,
); );
if (!isMemberOfWorkspace) { if (!workspace) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { return res.status(404).json({ error: "Workspace not found" });
status: 403, }
headers: { "Content-Type": "application/json" },
}); try {
await assertPermission(db, user.id, workspace.id, "workspace:manage");
} catch {
return res.status(403).json({ error: "Unauthorized" });
} }
const subscription = await subscriptionRepo.create(db, { const subscription = await subscriptionRepo.create(db, {
@@ -80,40 +104,46 @@ export default withRateLimit(
status: "incomplete", status: "incomplete",
}); });
const subscriptionId = subscription?.id; subscriptionId = subscription?.id;
if (!subscriptionId) { if (!subscriptionId) {
return res.status(500).json({ error: "Error creating subscription" }); 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({ const session = await stripe.checkout.sessions.create({
mode: "subscription", mode: "subscription",
payment_method_collection: "always", payment_method_collection: "always",
line_items: [ line_items: [{ price: priceId, quantity: 1 }],
{ subscription_data: { trial_period_days: 14 },
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}`, success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: workspacePublicId, client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined, customer: user.stripeCustomerId ?? undefined,
metadata: { metadata: {
...(slug && { workspaceSlug: slug }), workspacePublicId: resolvedWorkspacePublicId!,
workspacePublicId,
userId: user.id, userId: user.id,
subscriptionId, 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 }); return res.status(200).json({ url: session.url });
} catch (error) { } catch (error) {
console.error("Error:", error);
return res.status(500).json({ error: "Error creating checkout session" }); return res.status(500).json({ error: "Error creating checkout session" });
} }
}, }),
); );

View File

@@ -44,20 +44,46 @@ export default async function handler(
const { db } = await createNextApiContext(req); 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) { switch (event.type) {
case "checkout.session.completed": { case "checkout.session.completed": {
const checkoutSession = event.data.object; const checkoutSession = event.data.object;
const meta = checkoutSession.metadata;
const metaData = checkoutSession.metadata; if (!meta?.workspacePublicId) break;
if (metaData?.workspacePublicId) { if (
await workspaceRepo.update(db, metaData.workspacePublicId, { meta.isNewWorkspace === "true" &&
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }), meta.workspaceName &&
plan: "pro", 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; break;
} }
default: default: