Files
zeekay 9dff9e667e rip: enterprise dirs + paywalls — full AGPL, no commercial-license code
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.

Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
  dataroom-invitations,access-notifications,security,conversions}/` →
  `features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
  unlimited profile (no users/links/documents/domains/datarooms cap,
  conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
  `@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
  retention routes now compile against `lib/billing/cancellation.ts`
  stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
  `lib/billing/paused.ts` always report active. `CancellationModal` is
  a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
  SCIM, AI chat, workflows, FAQ, link-upload).

Build: `npm run build` green.
2026-06-07 11:46:48 -07:00

210 lines
6.7 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,
proAnnualBanner,
return_url,
applyYearlyDiscount,
type = "manage",
} = req.body as {
priceId: string;
upgradePlan: boolean;
quantity?: number;
addSeat?: boolean;
proAnnualBanner?: 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,
action: proAnnualBanner ? "pro-annual-banner" : undefined,
}),
);
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`);
}
}