feat: create worspace on checkout.session.completed
This commit is contained in:
@@ -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,103 +20,130 @@ 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") {
|
||||||
return res.status(405).json({ error: "Method not allowed" });
|
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = req.body as CheckoutSessionRequest;
|
try {
|
||||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
const { user, db } = await createNextApiContext(req);
|
||||||
|
|
||||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
if (!user) {
|
||||||
return res.status(400).json({ error: "Missing required fields" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
}
|
|
||||||
|
|
||||||
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" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
const body = req.body as CheckoutSessionRequest;
|
||||||
|
const {
|
||||||
const isMemberOfWorkspace = workspace.some(
|
successUrl,
|
||||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
cancelUrl,
|
||||||
);
|
billing,
|
||||||
|
|
||||||
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 }),
|
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
userId: user.id,
|
slug,
|
||||||
subscriptionId,
|
workspaceName,
|
||||||
},
|
workspaceDescription,
|
||||||
});
|
workspaceSlug,
|
||||||
|
} = body;
|
||||||
|
|
||||||
return res.status(200).json({ url: session.url });
|
if (!successUrl || !cancelUrl) {
|
||||||
} catch (error) {
|
return res.status(400).json({ error: "Missing required fields" });
|
||||||
console.error("Error:", error);
|
}
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
|
||||||
}
|
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" });
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user