feat(cloud): enable upgrading new and existing workspaces onto team/pro
This commit is contained in:
@@ -20,13 +20,13 @@ const workspaceSlugSchema = z
|
|||||||
interface CheckoutSessionRequest {
|
interface CheckoutSessionRequest {
|
||||||
successUrl: string;
|
successUrl: string;
|
||||||
cancelUrl: string;
|
cancelUrl: string;
|
||||||
|
plan: "team" | "pro";
|
||||||
billing?: string;
|
billing?: string;
|
||||||
workspacePublicId?: string;
|
workspacePublicId?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
workspaceName?: string;
|
workspaceName?: string;
|
||||||
workspaceDescription?: string;
|
workspaceDescription?: string;
|
||||||
workspaceSlug?: string;
|
workspaceSlug?: string;
|
||||||
plan?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRateLimit(
|
export default withRateLimit(
|
||||||
@@ -38,117 +38,114 @@ export default withRateLimit(
|
|||||||
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);
|
||||||
const { user, db } = await createNextApiContext(req);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({ error: "User not found" });
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body as CheckoutSessionRequest;
|
||||||
|
const {
|
||||||
|
successUrl,
|
||||||
|
cancelUrl,
|
||||||
|
plan,
|
||||||
|
billing,
|
||||||
|
workspacePublicId,
|
||||||
|
slug,
|
||||||
|
workspaceName,
|
||||||
|
workspaceDescription,
|
||||||
|
workspaceSlug,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!successUrl || !cancelUrl || !plan) {
|
||||||
|
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" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = req.body as CheckoutSessionRequest;
|
let resolvedWorkspacePublicId = workspacePublicId;
|
||||||
const {
|
let subscriptionId: number | undefined;
|
||||||
successUrl,
|
|
||||||
cancelUrl,
|
if (workspacePublicId) {
|
||||||
billing,
|
// Existing workspace upgrade
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
db,
|
||||||
workspacePublicId,
|
workspacePublicId,
|
||||||
slug,
|
);
|
||||||
workspaceName,
|
|
||||||
workspaceDescription,
|
|
||||||
workspaceSlug,
|
|
||||||
} = body;
|
|
||||||
|
|
||||||
if (!successUrl || !cancelUrl) {
|
if (!workspace) {
|
||||||
return res.status(400).json({ error: "Missing required fields" });
|
return res.status(404).json({ error: "Workspace not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspacePublicId && !workspaceName) {
|
try {
|
||||||
return res
|
await assertPermission(db, user.id, workspace.id, "workspace:manage");
|
||||||
.status(400)
|
} catch {
|
||||||
.json({ error: "Must provide workspacePublicId or workspaceName" });
|
return res.status(403).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedSlug = slug ?? workspaceSlug;
|
const subscription = await subscriptionRepo.create(db, {
|
||||||
|
plan: plan,
|
||||||
if (resolvedSlug) {
|
referenceId: workspacePublicId,
|
||||||
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
|
userId: user.id,
|
||||||
if (!slugResult.success) {
|
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||||
return res.status(400).json({ error: "Invalid workspace slug" });
|
status: "incomplete",
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 isTeam = body.plan === "team";
|
|
||||||
const annualPriceId = isTeam
|
|
||||||
? (process.env.STRIPE_TEAM_PLAN_ANNUAL_PRICE_ID ??
|
|
||||||
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
|
||||||
: (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ??
|
|
||||||
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
|
||||||
const monthlyPriceId = isTeam
|
|
||||||
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
|
||||||
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
|
||||||
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
|
||||||
|
|
||||||
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}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
|
||||||
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 });
|
subscriptionId = subscription?.id;
|
||||||
} catch (error) {
|
|
||||||
return res.status(500).json({ error: "Error creating checkout session" });
|
if (!subscriptionId) {
|
||||||
|
return res.status(500).json({ error: "Error creating subscription" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedWorkspacePublicId = generateUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTeam = body.plan === "team";
|
||||||
|
const annualPriceId = isTeam
|
||||||
|
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
|
||||||
|
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
||||||
|
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
|
||||||
|
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
||||||
|
const monthlyPriceId = isTeam
|
||||||
|
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
||||||
|
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
||||||
|
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
||||||
|
|
||||||
|
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}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
||||||
|
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: plan,
|
||||||
|
isNewWorkspace: workspaceName ? "true" : "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ url: session.url });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ export function withApiLogging(
|
|||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
const route = req.url?.split("?")[0] ?? "unknown";
|
const route = req.url?.split("?")[0] ?? "unknown";
|
||||||
const input = {
|
const input = {
|
||||||
...(req.query && Object.keys(req.query).length > 0 && { query: req.query }),
|
...(req.query &&
|
||||||
...(req.body && typeof req.body === "object" && Object.keys(req.body).length > 0 && { body: req.body }),
|
Object.keys(req.query).length > 0 && { query: req.query }),
|
||||||
|
...(req.body &&
|
||||||
|
typeof req.body === "object" &&
|
||||||
|
Object.keys(req.body).length > 0 && { body: req.body }),
|
||||||
};
|
};
|
||||||
|
|
||||||
let statusCode = 200;
|
let statusCode = 200;
|
||||||
@@ -41,7 +44,16 @@ export function withApiLogging(
|
|||||||
// unauthenticated or auth unavailable
|
// unauthenticated or auth unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
await handler(req, res);
|
let handlerError: unknown;
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
handlerError = err;
|
||||||
|
statusCode = 500;
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
const meta = {
|
const meta = {
|
||||||
@@ -53,6 +65,10 @@ export function withApiLogging(
|
|||||||
...(isCloud && email && { email }),
|
...(isCloud && email && { email }),
|
||||||
...(Object.keys(input).length > 0 && { input }),
|
...(Object.keys(input).length > 0 && { input }),
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
|
...(handlerError instanceof Error && {
|
||||||
|
error: handlerError.message,
|
||||||
|
stack: handlerError.stack,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (statusCode < 400) {
|
if (statusCode < 400) {
|
||||||
|
|||||||
Reference in New Issue
Block a user