Files
zooqueen 8e355c35b9 billing: hand payment to the hosted pages
The dataroom carried its own plan catalog, feature grid and Stripe
price-id mapper with no payment SDK behind them, so nothing there could
take money. Plans and pricing now open pay.hanzo.ai, invoices and
payment method open billing.hanzo.ai, both named once in
lib/billing/hosted.ts.

UpgradePlanModal keeps its name, props and children API across all 45
call sites; clickedPlan rides the analytics event and highlightItem no
longer has an effect, since the hosted page owns the catalog.
2026-08-14 20:50:04 -07:00

207 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/lib/billing/legacy";
import { getCouponFromPlan } from "@/lib/billing/legacy/functions/get-coupon-from-plan";
import { getQuantityFromPriceId } from "@/lib/billing/legacy/functions/get-quantity-from-plan";
import getSubscriptionItem from "@/lib/billing/legacy/functions/get-subscription-item";
import { getPlanFromPriceId, isOldAccount } from "@/lib/billing/legacy/utils";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import { identifyUser, trackAnalytics } from "@/lib/analytics";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { authOptions } from "../../../auth/[...nextauth]";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/manage – manage a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const userEmail = (session.user as CustomUser).email;
const { teamId } = req.query as { teamId: string };
const {
priceId,
upgradePlan,
quantity,
addSeat,
return_url,
applyYearlyDiscount,
type = "manage",
} = req.body as {
priceId: string;
upgradePlan: boolean;
quantity?: number;
addSeat?: boolean;
return_url?: string;
applyYearlyDiscount?: boolean;
type?:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update"
| "cancellation";
};
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exists" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No billing customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const {
id: subscriptionItemId,
currentPeriodStart,
currentPeriodEnd,
} = await getSubscriptionItem(
team.subscriptionId,
isOldAccount(team.plan),
);
const minQuantity = getQuantityFromPriceId(priceId);
const stripe = stripeInstance(isOldAccount(team.plan));
// Apply 30% discount for yearly plans before redirecting to billing portal
// Same logic as retention flow: apply coupon directly to subscription
if (applyYearlyDiscount && upgradePlan) {
const plan = getPlanFromPriceId(priceId, isOldAccount(team.plan));
if (plan) {
// Use the same logic as retention flow: getCouponFromPlan(team.plan, isAnnualPlan)
// team.plan format is "pro", "business", "pro+old", "business+old", etc.
const planString = `${plan.slug}${isOldAccount(team.plan) ? "+old" : ""}`;
const couponId = getCouponFromPlan(planString, true);
// Verify coupon exists before applying (coupons might only exist in production, not test mode)
try {
await stripe.coupons.retrieve(couponId);
// Apply discount directly to subscription (same as retention flow)
await stripe.subscriptions.update(team.subscriptionId, {
discounts: [{ coupon: couponId }],
});
} catch (error: any) {
// If coupon doesn't exist (common in test mode), log and skip
if (error.code === "resource_missing") {
console.warn(
`[Manage] Coupon "${couponId}" not found in ${process.env.NEXT_PUBLIC_VERCEL_ENV || "test"} mode. ` +
`Skipping discount application. This is expected if the coupon only exists in production.`
);
} else {
// Re-throw other errors
throw error;
}
}
}
}
const { url } = await stripe.billingPortal.sessions.create({
customer: team.stripeId,
return_url: `${process.env.NEXTAUTH_URL}/settings/billing?cancel=true`,
...(type === "manage" &&
(upgradePlan || addSeat) &&
subscriptionItemId && {
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: team.subscriptionId,
items: [
{
id: subscriptionItemId,
quantity: isOldAccount(team.plan)
? 1
: (quantity ?? minQuantity),
price: priceId,
},
],
},
after_completion: {
type: "redirect",
redirect: {
return_url:
return_url ??
`${process.env.NEXTAUTH_URL}/settings/billing?success=true`,
},
},
},
}),
...(type === "subscription_update" && {
flow_data: {
type: "subscription_update",
subscription_update: {
subscription: team.subscriptionId,
},
},
}),
...(type === "cancellation" && {
flow_data: {
type: "subscription_cancel",
subscription_cancel: {
subscription: team.subscriptionId,
},
after_completion: {
type: "redirect",
redirect: {
return_url:
return_url ??
`${process.env.NEXTAUTH_URL}/settings/billing?cancellation=true`,
},
},
},
}),
});
waitUntil(identifyUser(userEmail ?? userId));
waitUntil(
trackAnalytics({
event: "Stripe Billing Portal Clicked",
teamId,
}),
);
return res.status(200).json(url);
} catch (error) {
errorhandler(error, res);
}
} else {
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}