Compare commits

...
Author SHA1 Message Date
Cursor AgentandMarc Seitz 21308e6c8b refactor: dual pricing model (base + per-seat) for Business and Data Rooms plans
- PLANS structure: replace per-user pricing with flat base price + per-seat addon
  for Business, Data Rooms, Data Rooms Plus, and Data Rooms Premium plans.
  Pro plan remains single-price.
- New fields: includedUsers, perSeat pricing with separate priceIds
- Old per-user price IDs moved to HISTORICAL_PRICE_IDS for backward compat
- Checkout (upgrade.ts): creates two line items for dual-pricing plans
- Manage (manage.ts): handles subscription updates with dual items
- Webhooks: use getPlanFromSubscriptionItems() to extract plan info and
  user count from both dual-item and legacy single-item subscriptions
- getSubscriptionItem: returns both base and perSeat item IDs
- UI: upgrade modal, add seat modal, upgrade page, holiday offer page
  all pass perSeatPriceId alongside base priceId
- Constants: updated included users from 3/5/10 to 1 per plan,
  updated per-user pricing display (Business: €26.50/mo)
- Plan limits: users default set to 1 for all dual-pricing plans

NOTE: price_TODO_* placeholders need to be replaced with real Stripe
Price IDs after creating the corresponding prices in Stripe Dashboard.

Co-authored-by: Marc Seitz <mfts@users.noreply.github.com>
2026-02-25 08:45:33 +00:00
16 changed files with 779 additions and 291 deletions
+28 -14
View File
@@ -4,8 +4,10 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { getQuantityFromPriceId } from "@/ee/stripe/functions/get-quantity-from-plan";
import {
getPriceIdFromPlan,
getPerSeatPriceIdFromPlan,
} from "@/ee/stripe/functions/get-price-id-from-plan";
import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
@@ -44,13 +46,18 @@ export function AddSeatModal({
const [quantity, setQuantity] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
// Get the minimum quantity for the current plan
const period = isAnnualPlan ? "yearly" : "monthly";
const priceId = getPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period: isAnnualPlan ? "yearly" : "monthly",
period,
});
const minQuantity = getQuantityFromPriceId(priceId);
const perSeatPriceId = getPerSeatPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period,
});
const hasDualPricing = !!perSeatPriceId;
// Set initial quantity to 1 (adding one seat)
useEffect(() => {
@@ -76,21 +83,28 @@ export function AddSeatModal({
setLoading(true);
try {
const body: Record<string, unknown> = {
priceId,
addSeat: true,
};
if (hasDualPricing) {
// For dual pricing, quantity is the total number of additional users (per-seat count)
body.perSeatPriceId = perSeatPriceId;
body.quantity = totalSeatsAfterUpdate - 1; // subtract the 1 included user
} else {
body.quantity = totalSeatsAfterUpdate;
}
const response = await fetch(`/api/teams/${teamId}/billing/manage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId,
quantity: totalSeatsAfterUpdate,
addSeat: true,
// return_url: `${process.env.NEXTAUTH_URL}/settings/people?success=true`,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
const error = await response.json();
toast.error("Unable to add seats. Please contact support.");
setLoading(false);
return;
@@ -174,9 +188,9 @@ export function AddSeatModal({
{totalSeatsAfterUpdate === 1 ? "user" : "users"}
</p>
{minQuantity > 1 && (
{hasDualPricing && (
<p className="mt-2 text-center text-sm text-muted-foreground">
Minimum quantity for {planName}: {minQuantity} users
1 user included in base plan. Additional users billed separately.
</p>
)}
</div>
+30 -4
View File
@@ -7,7 +7,10 @@ import React from "react";
import { useTeam } from "@/context/team-context";
import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import {
getPriceIdFromPlan,
getPerSeatPriceIdFromPlan,
} from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
@@ -300,6 +303,19 @@ export function UpgradePlanModal({
<span className="text-gray-500 dark:text-white/75">
/month{period === "yearly" && ", billed annually"}
</span>
{PLANS.find((p) => p.name === displayPlanName)?.price[
period
].perSeat && (
<p className="mt-1 text-sm text-gray-500 dark:text-white/60">
+
{
PLANS.find((p) => p.name === displayPlanName)?.price[
period
].perSeat?.amount
}
/mo per additional user
</p>
)}
</div>
{planOption === PlanEnum.DataRooms &&
@@ -346,6 +362,11 @@ export function UpgradePlanModal({
period,
isOld: isOldAccount,
});
const perSeatPriceId = getPerSeatPriceIdFromPlan({
planName: displayPlanName,
period,
isOld: isOldAccount,
});
setSelectedPlan(planOption);
if (isCustomer && teamPlan !== "free") {
@@ -356,6 +377,7 @@ export function UpgradePlanModal({
},
body: JSON.stringify({
priceId,
perSeatPriceId,
upgradePlan: true,
}),
})
@@ -368,10 +390,14 @@ export function UpgradePlanModal({
setSelectedPlan(null);
});
} else {
const params = new URLSearchParams({
priceId: priceId!,
});
if (perSeatPriceId) {
params.set("perSeatPriceId", perSeatPriceId);
}
fetch(
`/api/teams/${teamId}/billing/upgrade?priceId=${
priceId
}`,
`/api/teams/${teamId}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
@@ -44,22 +44,27 @@ export function RetentionOfferModal({
const currentQuantity = limits?.users ?? 1;
const calculateSavings = () => {
// Find current plan pricing
const currentPlan = PLANS.find((p) => p.slug === userPlan);
if (!currentPlan) return { savings: "€0" };
const monthlyPrice = currentPlan.price.monthly.unitPrice;
const yearlyPrice = currentPlan.price.yearly.unitPrice;
// Simple logic: 30% discount for 3 months (monthly) or 12 months (annual)
const discountPercent = 0.3;
const durationMonths = isAnnualPlan ? 12 : 3;
const basePrice = isAnnualPlan ? yearlyPrice : monthlyPrice;
const period = isAnnualPlan ? "yearly" : "monthly";
const pricing = currentPlan.price[period];
// Calculate savings
const totalSavings = Math.round(
(basePrice * durationMonths * discountPercent * currentQuantity) / 100,
);
// Base plan amount (flat price in euros)
let monthlyTotal = pricing.amount;
// Add per-seat cost for additional users beyond the included count
const additionalUsers = Math.max(0, currentQuantity - currentPlan.includedUsers);
if (pricing.perSeat && additionalUsers > 0) {
monthlyTotal += pricing.perSeat.amount * additionalUsers;
} else if (!pricing.perSeat) {
// Legacy single-price plan: amount is per-user
monthlyTotal = pricing.amount * currentQuantity;
}
const totalSavings = Math.round(monthlyTotal * durationMonths * discountPercent);
return { savings: `${totalSavings}` };
};
+4 -4
View File
@@ -36,7 +36,7 @@ export const PRO_PLAN_LIMITS = {
};
export const BUSINESS_PLAN_LIMITS = {
users: 3,
users: 1,
links: null,
documents: null,
domains: 5,
@@ -50,7 +50,7 @@ export const BUSINESS_PLAN_LIMITS = {
};
export const DATAROOMS_PLAN_LIMITS = {
users: 3,
users: 1,
links: null,
documents: null,
domains: 10,
@@ -64,7 +64,7 @@ export const DATAROOMS_PLAN_LIMITS = {
};
export const DATAROOMS_PLUS_PLAN_LIMITS = {
users: 5,
users: 1,
links: null,
documents: null,
domains: 1000,
@@ -80,7 +80,7 @@ export const DATAROOMS_PLUS_PLAN_LIMITS = {
};
export const DATAROOMS_PREMIUM_PLAN_LIMITS = {
users: 10,
users: 1,
links: null,
documents: null,
domains: 1000,
+9 -9
View File
@@ -44,7 +44,7 @@ export const PLAN_PRICING = {
},
Business: {
extraUserPrice: {
monthly: "€26/month per additional team member",
monthly: "€26.50/month per additional team member",
yearly: "€19/month per additional team member",
},
},
@@ -91,9 +91,9 @@ export const BASE_FEATURES: Record<PlanEnum, PlanFeatures> = {
features: [
{
id: "users",
text: "3 team members included",
text: "1 team member included",
isUsers: true,
usersIncluded: 3,
usersIncluded: 1,
},
{
id: "datarooms",
@@ -123,9 +123,9 @@ export const BASE_FEATURES: Record<PlanEnum, PlanFeatures> = {
features: [
{
id: "users",
text: "3 team members included",
text: "1 team member included",
isUsers: true,
usersIncluded: 3,
usersIncluded: 1,
},
{ id: "datarooms", text: "Unlimited data rooms" },
// { id: "documents", text: "Unlimited documents" },
@@ -152,9 +152,9 @@ export const BASE_FEATURES: Record<PlanEnum, PlanFeatures> = {
features: [
{
id: "users",
text: "5 team members included",
text: "1 team member included",
isUsers: true,
usersIncluded: 5,
usersIncluded: 1,
},
{ id: "documents", text: "Unlimited documents in data rooms" },
{
@@ -185,9 +185,9 @@ export const BASE_FEATURES: Record<PlanEnum, PlanFeatures> = {
},
{
id: "users",
text: "10 team members included",
text: "1 team member included",
isUsers: true,
usersIncluded: 10,
usersIncluded: 1,
},
{ id: "storage", text: "Unlimited encrypted storage", highlight: true },
{ id: "file-size", text: "No file size limit" },
@@ -0,0 +1,63 @@
import Stripe from "stripe";
import {
Plan,
getPlanFromPriceId,
isPerSeatPriceId,
planHasDualPricing,
} from "../utils";
interface SubscriptionPlanInfo {
plan: Plan;
totalUsers: number;
additionalUsers: number;
}
/**
* Extracts plan info and total user count from a Stripe subscription's items.
*
* Handles both:
* - Dual-pricing subscriptions (base flat price + per-seat addon)
* - Legacy single-price subscriptions (per-user × quantity)
*/
export function getPlanFromSubscriptionItems(
items: Stripe.SubscriptionItem[],
isOldAccount: boolean = false,
): SubscriptionPlanInfo | null {
if (items.length === 0) return null;
let basePlan: Plan | null = null;
let baseQuantity = 1;
let perSeatQuantity = 0;
for (const item of items) {
const priceId = item.price.id;
const qty = item.quantity ?? 0;
if (isPerSeatPriceId(priceId, isOldAccount)) {
perSeatQuantity = qty;
} else {
basePlan = getPlanFromPriceId(priceId, isOldAccount) as Plan | null;
baseQuantity = qty;
}
}
if (!basePlan) return null;
const isDual = planHasDualPricing(basePlan);
if (isDual) {
// Dual pricing: base includes `includedUsers`, additional from per-seat item
const totalUsers = basePlan.includedUsers + perSeatQuantity;
return { plan: basePlan, totalUsers, additionalUsers: perSeatQuantity };
}
// Legacy single-price: quantity IS the total user count
const totalUsers =
baseQuantity > basePlan.includedUsers ? baseQuantity : basePlan.includedUsers;
return {
plan: basePlan,
totalUsers,
additionalUsers: Math.max(0, baseQuantity - basePlan.includedUsers),
};
}
+47 -4
View File
@@ -1,6 +1,6 @@
import { PLANS, isOldAccount } from "../utils";
import { PLANS, isOldAccount, planHasDualPricing } from "../utils";
export function getPriceIdFromPlan({
function resolvePlan({
planSlug,
planName,
isOld,
@@ -31,8 +31,51 @@ export function getPriceIdFromPlan({
if (!plan) {
console.error(`Plan not found: ${cleanPlan}`);
return undefined;
return null;
}
return plan.price[period].priceIds[env][accountType];
return { plan, env: env as "test" | "production", accountType: accountType as "old" | "new", period };
}
/**
* Returns the base (flat) price ID for a plan.
* For dual-pricing plans this is the flat plan charge;
* for single-price plans (Pro) this is the per-user price.
*/
export function getPriceIdFromPlan({
planSlug,
planName,
isOld,
period,
}: {
planSlug?: string;
planName?: string;
isOld?: boolean;
period: "monthly" | "yearly";
}) {
const result = resolvePlan({ planSlug, planName, isOld, period });
if (!result) return undefined;
const { plan, env, accountType, period: p } = result;
return plan.price[p].priceIds[env][accountType];
}
/**
* Returns the per-seat addon price ID for plans with dual pricing.
* Returns undefined for single-price plans (Pro).
*/
export function getPerSeatPriceIdFromPlan({
planSlug,
planName,
isOld,
period,
}: {
planSlug?: string;
planName?: string;
isOld?: boolean;
period: "monthly" | "yearly";
}): string | undefined {
const result = resolvePlan({ planSlug, planName, isOld, period });
if (!result) return undefined;
const { plan, env, accountType, period: p } = result;
return plan.price[p].perSeat?.priceIds[env][accountType];
}
@@ -1,12 +1,17 @@
import { getPlanFromPriceId } from "../utils";
/**
* For dual-pricing plans this returns the includedUsers count (always 1 for the base item).
* For legacy single-price plans (Pro) this returns includedUsers (1).
* Used as the base quantity when creating/updating subscriptions.
*/
export function getQuantityFromPriceId(priceId?: string) {
if (!priceId) {
return 1;
}
try {
const plan = getPlanFromPriceId(priceId);
return plan?.minQuantity ?? 1;
return plan?.includedUsers ?? 1;
} catch (error) {
console.error("Error getting quantity for priceId: %s", priceId, error);
return 1;
+31 -4
View File
@@ -1,4 +1,5 @@
import { stripeInstance } from "..";
import { isPerSeatPriceId } from "../utils";
export interface SubscriptionDiscount {
couponId: string;
@@ -10,17 +11,42 @@ export interface SubscriptionDiscount {
end?: number;
}
export interface SubscriptionItems {
/** The base/plan subscription item ID */
id: string;
/** The per-seat addon subscription item ID (only for dual-pricing plans) */
perSeatId?: string;
currentPeriodStart: number;
currentPeriodEnd: number;
discount: SubscriptionDiscount | null;
}
export default async function getSubscriptionItem(
subscriptionId: string,
isOldAccount: boolean,
) {
): Promise<SubscriptionItems> {
const stripe = stripeInstance(isOldAccount);
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["discount.coupon"],
});
const subscriptionItem = subscription.items.data[0];
// Extract discount information if available
let baseItemId: string | undefined;
let perSeatItemId: string | undefined;
for (const item of subscription.items.data) {
const priceId = item.price.id;
if (isPerSeatPriceId(priceId, isOldAccount)) {
perSeatItemId = item.id;
} else {
baseItemId = item.id;
}
}
// Fallback: if we couldn't classify items, use the first one as base
if (!baseItemId && subscription.items.data.length > 0) {
baseItemId = subscription.items.data[0].id;
}
let discount: SubscriptionDiscount | null = null;
if (subscription.discount && subscription.discount.coupon) {
const coupon = subscription.discount.coupon;
@@ -36,7 +62,8 @@ export default async function getSubscriptionItem(
}
return {
id: subscriptionItem.id,
id: baseItemId!,
perSeatId: perSeatItemId,
currentPeriodStart: subscription.current_period_start,
currentPeriodEnd: subscription.current_period_end,
discount,
+249 -68
View File
@@ -1,15 +1,47 @@
import Stripe from "stripe";
// Historical price IDs that are no longer in the main PLANS configuration
// but still need to be supported for existing subscriptions
// but still need to be supported for existing subscriptions.
// This includes the old per-user price IDs from before the dual-pricing refactor.
const HISTORICAL_PRICE_IDS: Record<string, Record<string, string>> = {
production: {
// Business plan historical prices
price_1OuYeIFJyGSZ96lhwH58Y1kU: "business", // Old business plan
// Add more historical price IDs here as needed
price_1OuYeIFJyGSZ96lhwH58Y1kU: "business",
// Legacy per-user price IDs (pre dual-pricing refactor)
price_1Q3gbVFJyGSZ96lhf7hsZciQ: "business", // old account monthly
price_1Q8egwBYvhH6u7U7XKLGjgHL: "business", // new account monthly
price_1Q3gbVFJyGSZ96lhqqLhBNDv: "business", // old account yearly
price_1Q8egwBYvhH6u7U7wRU6iPcW: "business", // new account yearly
price_1Q3gbbFJyGSZ96lhvmEwjZtm: "datarooms", // old account monthly
price_1Q8egzBYvhH6u7U7IQUGzwoZ: "datarooms", // new account monthly
price_1Q3gbbFJyGSZ96lhnk1CtnIZ: "datarooms", // old account yearly
price_1Q8egzBYvhH6u7U7M2uoROMa: "datarooms", // new account yearly
price_1QwMmmFJyGSZ96lhhaDXmzkY: "datarooms-plus", // old account monthly
price_1QwMkABYvhH6u7U74ccUfWkq: "datarooms-plus", // new account monthly
price_1QwMmeFJyGSZ96lh934mFNPA: "datarooms-plus", // old account yearly
price_1QwMjABYvhH6u7U7ccxGJXKN: "datarooms-plus", // new account yearly
price_placeholder_prod_old: "datarooms-premium",
price_1SUWXqBYvhH6u7U7SJKKOCKU: "datarooms-premium",
price_placeholder_prod_yearly_old: "datarooms-premium",
price_1SUWWqBYvhH6u7U7I5MpZ43K: "datarooms-premium",
},
test: {
// Add test environment historical price IDs if needed
// Legacy per-user price IDs (pre dual-pricing refactor)
price_1Q3bPhFJyGSZ96lhnxpiJMwz: "business",
price_1Q8aWlBYvhH6u7U7gTeKJJ0Y: "business",
price_1Q3bQ5FJyGSZ96lhoS8QbYXr: "business",
price_1Q8aVSBYvhH6u7U72mn6iPfK: "business",
price_1Q3bHPFJyGSZ96lhpQD0lMdU: "datarooms",
price_1Q8aYLBYvhH6u7U7RUqHnsBh: "datarooms",
price_1Q3bJUFJyGSZ96lhLiEJlXlt: "datarooms",
price_1Q8aXWBYvhH6u7U7unPGTnfy: "datarooms",
price_1QojZuFJyGSZ96lhNwiD1y2r: "datarooms-plus",
price_1Qw63uBYvhH6u7U7dHVZ0kWZ: "datarooms-plus",
price_1QojaPFJyGSZ96lhods9TOxh: "datarooms-plus",
price_1Qw63ABYvhH6u7U7MXK3UOJF: "datarooms-plus",
price_placeholder_test_old: "datarooms-premium",
price_1SUWeXBYvhH6u7U7u7CJgsRE: "datarooms-premium",
price_placeholder_test_yearly_old: "datarooms-premium",
price_1SUWhQBYvhH6u7U7BE6vVLcf: "datarooms-premium",
},
};
@@ -19,21 +51,51 @@ function getHistoricalPlanFromPriceId(priceId: string, env: string) {
return null;
}
// Find the current plan configuration for this slug
const currentPlan = PLANS.find((plan) => plan.slug === planSlug);
if (!currentPlan) {
return null;
}
// Return a plan object that maintains the current plan structure
// but indicates it's from a historical price ID
return {
...currentPlan,
// Mark this as a historical price for logging purposes
_historical: true,
};
}
export type PriceIds = {
test: { old: string; new: string };
production: { old: string; new: string };
};
export type PlanPrice = {
amount: number;
priceIds: PriceIds;
perSeat?: {
amount: number;
priceIds: PriceIds;
};
};
export type Plan = {
name: string;
slug: string;
includedUsers: number;
price: {
monthly: PlanPrice;
yearly: PlanPrice;
};
_historical?: boolean;
};
/**
* Returns true if the plan uses dual pricing (flat base + per-seat addon).
* Plans with perSeat pricing show a flat base charge on the invoice
* instead of a confusing per-user × quantity line item.
*/
export function planHasDualPricing(plan: Plan): boolean {
return !!plan.price.monthly.perSeat;
}
export function getPlanFromPriceId(
priceId: string,
isOldAccount: boolean = false,
@@ -44,11 +106,12 @@ export function getPlanFromPriceId(
const plan = PLANS.find(
(plan) =>
plan.price.monthly.priceIds[env][accountType] === priceId ||
plan.price.yearly.priceIds[env][accountType] === priceId,
plan.price.yearly.priceIds[env][accountType] === priceId ||
plan.price.monthly.perSeat?.priceIds[env][accountType] === priceId ||
plan.price.yearly.perSeat?.priceIds[env][accountType] === priceId,
);
if (!plan) {
// Check historical price IDs for known legacy prices
const historicalPlan = getHistoricalPlanFromPriceId(priceId, env);
if (historicalPlan) {
console.log(
@@ -60,13 +123,29 @@ export function getPlanFromPriceId(
console.error(
`Plan not found for priceId: ${priceId}, isOldAccount: ${isOldAccount}, env: ${env}`,
);
// Return null instead of a fake free plan to prevent unintended downgrades
return null;
}
return plan;
}
/**
* Checks if a given priceId is a per-seat addon price (not a base price).
*/
export function isPerSeatPriceId(
priceId: string,
isOldAccount: boolean = false,
): boolean {
const env =
process.env.NEXT_PUBLIC_VERCEL_ENV === "production" ? "production" : "test";
const accountType = isOldAccount ? "old" : "new";
return PLANS.some(
(plan) =>
plan.price.monthly.perSeat?.priceIds[env][accountType] === priceId ||
plan.price.yearly.perSeat?.priceIds[env][accountType] === priceId,
);
}
// custom type coercion because Stripe's types are wrong
export function isNewCustomer(
previousAttributes: Partial<Stripe.Subscription> | undefined,
@@ -102,41 +181,47 @@ export function isUpgradedCustomer(
return isUpgradedUser;
}
export const PLANS = [
// TODO: Create the following Stripe Prices before going live:
//
// For each plan with dual pricing (Business, Data Rooms, DR Plus, DR Premium):
// 1. A FLAT recurring price for the base plan (not per-unit)
// - e.g. Business Monthly: €79/mo flat
// 2. A PER-UNIT recurring price for additional seats
// - e.g. Business Monthly: €26.50/mo per unit
//
// Replace the price_TODO_* placeholders below with real Stripe Price IDs.
// The old per-user price IDs have been moved to HISTORICAL_PRICE_IDS for
// backward compatibility with existing subscriptions.
export const PLANS: Plan[] = [
{
name: "Pro",
slug: "pro",
minQuantity: 1,
includedUsers: 1,
price: {
monthly: {
amount: 29,
unitPrice: 1950,
priceIds: {
test: {
old: "price_1Q3bcHFJyGSZ96lhElXBA5C1",
// new: "price_1Q8aUBBYvhH6u7U7LPIVxYpz",
new: "price_1QvgdNBYvhH6u7U7drrXAXM3", // exp
new: "price_1QvgdNBYvhH6u7U7drrXAXM3",
},
production: {
old: "price_1P3FK4FJyGSZ96lhD67yF3lj",
// new: "price_1Q8egtBYvhH6u7U7gq1Pbp5Z",
new: "price_1Qvk3LBYvhH6u7U7JE4V6JY0", // exp
new: "price_1Qvk3LBYvhH6u7U7JE4V6JY0",
},
},
},
yearly: {
amount: 24,
unitPrice: 1450,
priceIds: {
test: {
old: "price_1Q3bV9FJyGSZ96lhCYWIcmg5",
// new: "price_1Q8aTkBYvhH6u7U7kUiNTSLX",
new: "price_1QviTtBYvhH6u7U79PQ2rzMI", // exp
new: "price_1QviTtBYvhH6u7U79PQ2rzMI",
},
production: {
old: "price_1Q3gfNFJyGSZ96lh2jGhEadm",
// new: "price_1Q8egtBYvhH6u7U7T4ehn7SM",
new: "price_1Qvk3LBYvhH6u7U7kppryTjV", // exp
new: "price_1Qvk3LBYvhH6u7U7kppryTjV",
},
},
},
@@ -145,33 +230,57 @@ export const PLANS = [
{
name: "Business",
slug: "business",
minQuantity: 3,
includedUsers: 1,
price: {
monthly: {
amount: 79,
unitPrice: 2633,
priceIds: {
test: {
old: "price_1Q3bPhFJyGSZ96lhnxpiJMwz",
new: "price_1Q8aWlBYvhH6u7U7gTeKJJ0Y",
old: "price_TODO_BUSINESS_BASE_MONTHLY_TEST_OLD",
new: "price_TODO_BUSINESS_BASE_MONTHLY_TEST_NEW",
},
production: {
old: "price_1Q3gbVFJyGSZ96lhf7hsZciQ",
new: "price_1Q8egwBYvhH6u7U7XKLGjgHL",
old: "price_TODO_BUSINESS_BASE_MONTHLY_PROD_OLD",
new: "price_TODO_BUSINESS_BASE_MONTHLY_PROD_NEW",
},
},
perSeat: {
amount: 26.5,
priceIds: {
test: {
old: "price_TODO_BUSINESS_SEAT_MONTHLY_TEST_OLD",
new: "price_TODO_BUSINESS_SEAT_MONTHLY_TEST_NEW",
},
production: {
old: "price_TODO_BUSINESS_SEAT_MONTHLY_PROD_OLD",
new: "price_TODO_BUSINESS_SEAT_MONTHLY_PROD_NEW",
},
},
},
},
yearly: {
amount: 59,
unitPrice: 1967,
priceIds: {
test: {
old: "price_1Q3bQ5FJyGSZ96lhoS8QbYXr",
new: "price_1Q8aVSBYvhH6u7U72mn6iPfK",
old: "price_TODO_BUSINESS_BASE_YEARLY_TEST_OLD",
new: "price_TODO_BUSINESS_BASE_YEARLY_TEST_NEW",
},
production: {
old: "price_1Q3gbVFJyGSZ96lhqqLhBNDv",
new: "price_1Q8egwBYvhH6u7U7wRU6iPcW",
old: "price_TODO_BUSINESS_BASE_YEARLY_PROD_OLD",
new: "price_TODO_BUSINESS_BASE_YEARLY_PROD_NEW",
},
},
perSeat: {
amount: 19,
priceIds: {
test: {
old: "price_TODO_BUSINESS_SEAT_YEARLY_TEST_OLD",
new: "price_TODO_BUSINESS_SEAT_YEARLY_TEST_NEW",
},
production: {
old: "price_TODO_BUSINESS_SEAT_YEARLY_PROD_OLD",
new: "price_TODO_BUSINESS_SEAT_YEARLY_PROD_NEW",
},
},
},
},
@@ -180,33 +289,57 @@ export const PLANS = [
{
name: "Data Rooms",
slug: "datarooms",
minQuantity: 3,
includedUsers: 1,
price: {
monthly: {
amount: 149,
unitPrice: 4967,
priceIds: {
test: {
old: "price_1Q3bHPFJyGSZ96lhpQD0lMdU",
new: "price_1Q8aYLBYvhH6u7U7RUqHnsBh",
old: "price_TODO_DATAROOMS_BASE_MONTHLY_TEST_OLD",
new: "price_TODO_DATAROOMS_BASE_MONTHLY_TEST_NEW",
},
production: {
old: "price_1Q3gbbFJyGSZ96lhvmEwjZtm",
new: "price_1Q8egzBYvhH6u7U7IQUGzwoZ",
old: "price_TODO_DATAROOMS_BASE_MONTHLY_PROD_OLD",
new: "price_TODO_DATAROOMS_BASE_MONTHLY_PROD_NEW",
},
},
perSeat: {
amount: 49,
priceIds: {
test: {
old: "price_TODO_DATAROOMS_SEAT_MONTHLY_TEST_OLD",
new: "price_TODO_DATAROOMS_SEAT_MONTHLY_TEST_NEW",
},
production: {
old: "price_TODO_DATAROOMS_SEAT_MONTHLY_PROD_OLD",
new: "price_TODO_DATAROOMS_SEAT_MONTHLY_PROD_NEW",
},
},
},
},
yearly: {
amount: 99,
unitPrice: 3300,
priceIds: {
test: {
old: "price_1Q3bJUFJyGSZ96lhLiEJlXlt",
new: "price_1Q8aXWBYvhH6u7U7unPGTnfy",
old: "price_TODO_DATAROOMS_BASE_YEARLY_TEST_OLD",
new: "price_TODO_DATAROOMS_BASE_YEARLY_TEST_NEW",
},
production: {
old: "price_1Q3gbbFJyGSZ96lhnk1CtnIZ",
new: "price_1Q8egzBYvhH6u7U7M2uoROMa",
old: "price_TODO_DATAROOMS_BASE_YEARLY_PROD_OLD",
new: "price_TODO_DATAROOMS_BASE_YEARLY_PROD_NEW",
},
},
perSeat: {
amount: 33,
priceIds: {
test: {
old: "price_TODO_DATAROOMS_SEAT_YEARLY_TEST_OLD",
new: "price_TODO_DATAROOMS_SEAT_YEARLY_TEST_NEW",
},
production: {
old: "price_TODO_DATAROOMS_SEAT_YEARLY_PROD_OLD",
new: "price_TODO_DATAROOMS_SEAT_YEARLY_PROD_NEW",
},
},
},
},
@@ -215,33 +348,57 @@ export const PLANS = [
{
name: "Data Rooms Plus",
slug: "datarooms-plus",
minQuantity: 5,
includedUsers: 1,
price: {
monthly: {
amount: 349,
unitPrice: 6980,
priceIds: {
test: {
old: "price_1QojZuFJyGSZ96lhNwiD1y2r",
new: "price_1Qw63uBYvhH6u7U7dHVZ0kWZ",
old: "price_TODO_DRPLUS_BASE_MONTHLY_TEST_OLD",
new: "price_TODO_DRPLUS_BASE_MONTHLY_TEST_NEW",
},
production: {
old: "price_1QwMmmFJyGSZ96lhhaDXmzkY",
new: "price_1QwMkABYvhH6u7U74ccUfWkq",
old: "price_TODO_DRPLUS_BASE_MONTHLY_PROD_OLD",
new: "price_TODO_DRPLUS_BASE_MONTHLY_PROD_NEW",
},
},
perSeat: {
amount: 69,
priceIds: {
test: {
old: "price_TODO_DRPLUS_SEAT_MONTHLY_TEST_OLD",
new: "price_TODO_DRPLUS_SEAT_MONTHLY_TEST_NEW",
},
production: {
old: "price_TODO_DRPLUS_SEAT_MONTHLY_PROD_OLD",
new: "price_TODO_DRPLUS_SEAT_MONTHLY_PROD_NEW",
},
},
},
},
yearly: {
amount: 249,
unitPrice: 4980,
priceIds: {
test: {
old: "price_1QojaPFJyGSZ96lhods9TOxh",
new: "price_1Qw63ABYvhH6u7U7MXK3UOJF",
old: "price_TODO_DRPLUS_BASE_YEARLY_TEST_OLD",
new: "price_TODO_DRPLUS_BASE_YEARLY_TEST_NEW",
},
production: {
old: "price_1QwMmeFJyGSZ96lh934mFNPA",
new: "price_1QwMjABYvhH6u7U7ccxGJXKN",
old: "price_TODO_DRPLUS_BASE_YEARLY_PROD_OLD",
new: "price_TODO_DRPLUS_BASE_YEARLY_PROD_NEW",
},
},
perSeat: {
amount: 49,
priceIds: {
test: {
old: "price_TODO_DRPLUS_SEAT_YEARLY_TEST_OLD",
new: "price_TODO_DRPLUS_SEAT_YEARLY_TEST_NEW",
},
production: {
old: "price_TODO_DRPLUS_SEAT_YEARLY_PROD_OLD",
new: "price_TODO_DRPLUS_SEAT_YEARLY_PROD_NEW",
},
},
},
},
@@ -250,33 +407,57 @@ export const PLANS = [
{
name: "Data Rooms Premium",
slug: "datarooms-premium",
minQuantity: 10,
includedUsers: 1,
price: {
monthly: {
amount: 699,
unitPrice: 6990,
priceIds: {
test: {
old: "price_placeholder_test_old",
new: "price_1SUWeXBYvhH6u7U7u7CJgsRE",
old: "price_TODO_DRPREMIUM_BASE_MONTHLY_TEST_OLD",
new: "price_TODO_DRPREMIUM_BASE_MONTHLY_TEST_NEW",
},
production: {
old: "price_placeholder_prod_old",
new: "price_1SUWXqBYvhH6u7U7SJKKOCKU",
old: "price_TODO_DRPREMIUM_BASE_MONTHLY_PROD_OLD",
new: "price_TODO_DRPREMIUM_BASE_MONTHLY_PROD_NEW",
},
},
perSeat: {
amount: 70,
priceIds: {
test: {
old: "price_TODO_DRPREMIUM_SEAT_MONTHLY_TEST_OLD",
new: "price_TODO_DRPREMIUM_SEAT_MONTHLY_TEST_NEW",
},
production: {
old: "price_TODO_DRPREMIUM_SEAT_MONTHLY_PROD_OLD",
new: "price_TODO_DRPREMIUM_SEAT_MONTHLY_PROD_NEW",
},
},
},
},
yearly: {
amount: 549,
unitPrice: 5490,
priceIds: {
test: {
old: "price_placeholder_test_yearly_old",
new: "price_1SUWhQBYvhH6u7U7BE6vVLcf",
old: "price_TODO_DRPREMIUM_BASE_YEARLY_TEST_OLD",
new: "price_TODO_DRPREMIUM_BASE_YEARLY_TEST_NEW",
},
production: {
old: "price_placeholder_prod_yearly_old",
new: "price_1SUWWqBYvhH6u7U7I5MpZ43K",
old: "price_TODO_DRPREMIUM_BASE_YEARLY_PROD_OLD",
new: "price_TODO_DRPREMIUM_BASE_YEARLY_PROD_NEW",
},
},
perSeat: {
amount: 55,
priceIds: {
test: {
old: "price_TODO_DRPREMIUM_SEAT_YEARLY_TEST_OLD",
new: "price_TODO_DRPREMIUM_SEAT_YEARLY_TEST_NEW",
},
production: {
old: "price_TODO_DRPREMIUM_SEAT_YEARLY_PROD_OLD",
new: "price_TODO_DRPREMIUM_SEAT_YEARLY_PROD_NEW",
},
},
},
},
@@ -6,6 +6,7 @@ import {
PRO_PLAN_LIMITS,
} from "@/ee/limits/constants";
import { stripeInstance } from "@/ee/stripe";
import { getPlanFromSubscriptionItems } from "@/ee/stripe/functions/get-plan-from-subscription";
import { waitUntil } from "@vercel/functions";
import Stripe from "stripe";
@@ -15,8 +16,6 @@ import prisma from "@/lib/prisma";
import { sendUpgradeOneMonthCheckinEmailTask } from "@/lib/trigger/send-scheduled-email";
import { log } from "@/lib/utils";
import { getPlanFromPriceId } from "../utils";
export async function checkoutSessionCompleted(
event: Stripe.Event,
isOldAccount: boolean = false,
@@ -38,25 +37,30 @@ export async function checkoutSessionCompleted(
const subscription = await stripe.subscriptions.retrieve(
checkoutSession.subscription as string,
);
const priceId = subscription.items.data[0].price.id;
const subscriptionId = subscription.id;
const subscriptionStart = new Date(subscription.current_period_start * 1000);
const subscriptionEnd = new Date(subscription.current_period_end * 1000);
const quantity = subscription.items.data[0].quantity;
console.log("subscription", subscription);
console.log("subscription items", subscription.items.data);
const plan = getPlanFromPriceId(priceId, isOldAccount);
const planInfo = getPlanFromSubscriptionItems(
subscription.items.data,
isOldAccount,
);
if (!plan) {
if (!planInfo) {
const priceIds = subscription.items.data
.map((i) => i.price.id)
.join(", ");
await log({
message: `Invalid price ID in checkout.session.completed event: ${priceId}, isOldAccount: ${isOldAccount}. Skipping webhook processing to prevent unintended plan changes.`,
message: `Invalid price IDs in checkout.session.completed: ${priceIds}, isOldAccount: ${isOldAccount}. Skipping to prevent unintended plan changes.`,
type: "error",
});
return;
}
const { plan, totalUsers } = planInfo;
const stripeId = checkoutSession.customer.toString();
const teamId = checkoutSession.client_reference_id;
@@ -78,11 +82,8 @@ export async function checkoutSessionCompleted(
planLimits = structuredClone(DATAROOMS_PREMIUM_PLAN_LIMITS);
}
// Update the user limit in planLimits based on the subscription quantity
planLimits.users =
typeof quantity === "number" && quantity > 1 ? quantity : planLimits.users;
planLimits.users = Math.max(totalUsers, planLimits.users);
// Update the user with the subscription information and stripeId
const team = await prisma.team.update({
where: {
id: teamId,
@@ -94,7 +95,6 @@ export async function checkoutSessionCompleted(
startsAt: subscriptionStart,
endsAt: subscriptionEnd,
limits: planLimits,
// Clear cancellation and pause state when purchasing a new plan
cancelledAt: null,
pausedAt: null,
pauseStartsAt: null,
@@ -111,7 +111,6 @@ export async function checkoutSessionCompleted(
},
});
// if event creation time more than 1 hour ago, return
if (event.created < Date.now() / 1000 - 1 * 60 * 60) {
await log({
message: `Checkout session completed event created more than 1 hour ago: ${event.id}`,
@@ -120,7 +119,6 @@ export async function checkoutSessionCompleted(
return;
}
// Send thank you email to project owner if they are a new customer
waitUntil(
sendUpgradePlanEmail({
user: {
@@ -131,7 +129,6 @@ export async function checkoutSessionCompleted(
}),
);
// send personal welcome email
waitUntil(
sendUpgradePersonalEmail({
user: {
@@ -7,31 +7,36 @@ import {
DATAROOMS_PREMIUM_PLAN_LIMITS,
PRO_PLAN_LIMITS,
} from "@/ee/limits/constants";
import { getPlanFromSubscriptionItems } from "@/ee/stripe/functions/get-plan-from-subscription";
import Stripe from "stripe";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
import { getPlanFromPriceId } from "../utils";
export async function customerSubsciptionUpdated(
event: Stripe.Event,
res: NextApiResponse,
isOldAccount: boolean = false,
) {
const subscriptionUpdated = event.data.object as Stripe.Subscription;
const priceId = subscriptionUpdated.items.data[0].price.id;
const plan = getPlanFromPriceId(priceId, isOldAccount);
const planInfo = getPlanFromSubscriptionItems(
subscriptionUpdated.items.data,
isOldAccount,
);
if (!plan) {
if (!planInfo) {
const priceIds = subscriptionUpdated.items.data
.map((i) => i.price.id)
.join(", ");
await log({
message: `Invalid price ID in customer.subscription.updated event: ${priceId}, isOldAccount: ${isOldAccount}. Skipping webhook processing to prevent unintended plan changes.`,
message: `Invalid price IDs in customer.subscription.updated: ${priceIds}, isOldAccount: ${isOldAccount}. Skipping.`,
type: "error",
});
return res.status(200).json({ received: true });
}
const { plan, totalUsers } = planInfo;
const stripeId = subscriptionUpdated.customer.toString();
const team = await prisma.team.findUnique({
@@ -53,16 +58,13 @@ export async function customerSubsciptionUpdated(
const subscriptionId = subscriptionUpdated.id;
const startsAt = new Date(subscriptionUpdated.current_period_start * 1000);
const endsAt = new Date(subscriptionUpdated.current_period_end * 1000);
const quantity = subscriptionUpdated.items.data[0].quantity;
let teamPlan = team.plan;
if (isOldAccount) {
// remove +old from plan
teamPlan = teamPlan.replace("+old", "");
}
// If a team upgrades/downgrades their subscription, update their plan
if (teamPlan !== newPlan) {
// Choose the correct plan limits
let planLimits:
| typeof PRO_PLAN_LIMITS
| typeof BUSINESS_PLAN_LIMITS
@@ -81,13 +83,8 @@ export async function customerSubsciptionUpdated(
planLimits = structuredClone(DATAROOMS_PREMIUM_PLAN_LIMITS);
}
// Update the user limit in planLimits based on the subscription quantity
planLimits.users =
typeof quantity === "number" && quantity > 1
? quantity
: planLimits.users;
planLimits.users = Math.max(totalUsers, planLimits.users);
// Update the user with the subscription information and stripeId
await prisma.team.update({
where: { stripeId },
data: {
@@ -100,15 +97,14 @@ export async function customerSubsciptionUpdated(
});
}
// If new account, and the plan is the same, but the quantity is different, update the quantity
// If same plan but user count changed, update the limit
if (
!isOldAccount &&
teamPlan === newPlan &&
(team.limits as any)?.users !== quantity
(team.limits as any)?.users !== totalUsers
) {
// Update the user limit in planLimits based on the subscription quantity
const newLimits = team.limits as any;
newLimits.users = quantity;
const newLimits = { ...(team.limits as any) };
newLimits.users = totalUsers;
await prisma.team.update({
where: { stripeId },
data: {
@@ -121,7 +117,6 @@ export async function customerSubsciptionUpdated(
});
}
// Update the subscription start and end dates
await prisma.team.update({
where: { stripeId },
data: {
+68 -33
View File
@@ -2,9 +2,12 @@ import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { getCouponFromPlan } from "@/ee/stripe/functions/get-coupon-from-plan";
import { getQuantityFromPriceId } from "@/ee/stripe/functions/get-quantity-from-plan";
import getSubscriptionItem from "@/ee/stripe/functions/get-subscription-item";
import { getPlanFromPriceId, isOldAccount } from "@/ee/stripe/utils";
import {
getPlanFromPriceId,
isOldAccount,
planHasDualPricing,
} from "@/ee/stripe/utils";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
@@ -16,7 +19,6 @@ import { CustomUser } from "@/lib/types";
import { authOptions } from "../../../auth/[...nextauth]";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
@@ -25,7 +27,6 @@ export default async function handle(
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");
@@ -38,6 +39,7 @@ export default async function handle(
const { teamId } = req.query as { teamId: string };
const {
priceId,
perSeatPriceId,
upgradePlan,
quantity,
addSeat,
@@ -47,6 +49,7 @@ export default async function handle(
type = "manage",
} = req.body as {
priceId: string;
perSeatPriceId?: string;
upgradePlan: boolean;
quantity?: number;
addSeat?: boolean;
@@ -88,70 +91,102 @@ export default async function handle(
return res.status(400).json({ error: "No subscription ID" });
}
const {
id: subscriptionItemId,
currentPeriodStart,
currentPeriodEnd,
} = await getSubscriptionItem(
const subscriptionData = 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.`
`[Manage] Coupon "${couponId}" not found in ${process.env.NEXT_PUBLIC_VERCEL_ENV || "test"} mode. Skipping.`,
);
} else {
// Re-throw other errors
throw error;
}
}
}
}
// Build subscription update items for billing portal
let updateItems: Array<{
id: string;
quantity: number;
price: string;
}> = [];
const plan = priceId
? getPlanFromPriceId(priceId, isOldAccount(team.plan))
: null;
const isDual = plan && planHasDualPricing(plan);
if (
type === "manage" &&
(upgradePlan || addSeat) &&
subscriptionData.id
) {
if (isDual && perSeatPriceId) {
// Dual pricing: update base item to qty 1, and per-seat item to the requested quantity
updateItems.push({
id: subscriptionData.id,
quantity: 1,
price: priceId,
});
if (addSeat) {
// When adding seats, the quantity is the total additional users
const perSeatItemId = subscriptionData.perSeatId;
if (perSeatItemId) {
updateItems.push({
id: perSeatItemId,
quantity: quantity ?? 0,
price: perSeatPriceId,
});
}
} else {
// New subscription upgrade: start with 0 additional seats
updateItems.push({
id: subscriptionData.perSeatId || subscriptionData.id,
quantity: 0,
price: perSeatPriceId,
});
}
} else {
// Single pricing (Pro) or legacy subscription
updateItems.push({
id: subscriptionData.id,
quantity: isOldAccount(team.plan)
? 1
: (quantity ?? plan?.includedUsers ?? 1),
price: priceId,
});
}
}
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 && {
updateItems.length > 0 && {
flow_data: {
type: "subscription_update_confirm",
subscription_update_confirm: {
subscription: team.subscriptionId,
items: [
{
id: subscriptionItemId,
quantity: isOldAccount(team.plan)
? 1
: (quantity ?? minQuantity),
price: priceId,
},
],
items: updateItems,
},
after_completion: {
type: "redirect",
+51 -29
View File
@@ -3,7 +3,11 @@ import { NextApiRequest, NextApiResponse } from "next";
import { checkRateLimit, rateLimiters } from "@/ee/features/security";
import { stripeInstance } from "@/ee/stripe";
import { getCouponFromPlan } from "@/ee/stripe/functions/get-coupon-from-plan";
import { getPlanFromPriceId, isOldAccount } from "@/ee/stripe/utils";
import {
getPlanFromPriceId,
isOldAccount,
planHasDualPricing,
} from "@/ee/stripe/utils";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
@@ -16,7 +20,6 @@ import { getIpAddress } from "@/lib/utils/ip";
import { authOptions } from "../../../auth/[...nextauth]";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
@@ -25,7 +28,6 @@ export default async function handle(
res: NextApiResponse,
) {
if (req.method === "POST") {
// Apply rate limiting
const clientIP = getIpAddress(req.headers);
const rateLimitResult = await checkRateLimit(
rateLimiters.billing,
@@ -46,11 +48,13 @@ export default async function handle(
return;
}
const { teamId, priceId, applyYearlyDiscount } = req.query as {
teamId: string;
priceId: string;
applyYearlyDiscount?: string;
};
const { teamId, priceId, perSeatPriceId, applyYearlyDiscount } =
req.query as {
teamId: string;
priceId: string;
perSeatPriceId?: string;
applyYearlyDiscount?: string;
};
const { id: userId, email: userEmail } = session.user as CustomUser;
@@ -79,49 +83,70 @@ export default async function handle(
return;
}
const minimumQuantity = plan.minQuantity;
const isDual = planHasDualPricing(plan);
let stripeSession;
let couponId: string | undefined;
// Apply 30% coupon for yearly plans if requested (same as retention flow)
// Since the upgrade modal is yearly-only, if applyYearlyDiscount is true, always apply
if (applyYearlyDiscount === "true") {
// Use the same logic as retention flow: getCouponFromPlan(team.plan, isAnnualPlan)
// team.plan format is "pro", "business", "pro+old", "business+old", etc.
const planString = oldAccount ? `${plan.slug}+old` : plan.slug;
couponId = getCouponFromPlan(planString, true);
// Verify coupon exists in Stripe (coupons might only exist in production, not test mode)
const stripe = stripeInstance(oldAccount);
try {
await stripe.coupons.retrieve(couponId);
} catch (error: any) {
// If coupon doesn't exist (common in test mode), continue without discount
if (error.code === "resource_missing") {
console.warn(
`[Upgrade] Coupon "${couponId}" not found in ${process.env.NEXT_PUBLIC_VERCEL_ENV || "test"} mode. ` +
`Continuing without discount. This is expected if the coupon only exists in production.`
`Continuing without discount.`,
);
couponId = undefined;
} else {
// Re-throw other errors
throw error;
}
}
}
const lineItem = {
price: priceId,
quantity: oldAccount ? 1 : minimumQuantity,
...(!oldAccount && {
const lineItems: Array<{
price: string;
quantity: number;
adjustable_quantity?: {
enabled: boolean;
minimum: number;
maximum: number;
};
}> = [];
if (isDual && perSeatPriceId) {
// Dual pricing: flat base + per-seat addon
lineItems.push({
price: priceId,
quantity: 1,
});
lineItems.push({
price: perSeatPriceId,
quantity: 0,
adjustable_quantity: {
enabled: true,
minimum: minimumQuantity,
minimum: 0,
maximum: 99,
},
}),
};
});
} else {
// Single pricing (Pro) or legacy fallback
lineItems.push({
price: priceId,
quantity: oldAccount ? 1 : plan.includedUsers,
...(!oldAccount && {
adjustable_quantity: {
enabled: true,
minimum: plan.includedUsers,
maximum: 99,
},
}),
});
}
const dubDiscount = await getDubDiscountForExternalUserId(userId);
@@ -130,7 +155,7 @@ export default async function handle(
billing_address_collection: "required" as const,
success_url: `${process.env.NEXTAUTH_URL}/settings/billing?success=true`,
cancel_url: `${process.env.NEXTAUTH_URL}/settings/billing?cancel=true`,
line_items: [lineItem],
line_items: lineItems,
automatic_tax: {
enabled: true,
},
@@ -145,7 +170,6 @@ export default async function handle(
};
if (team.stripeId) {
// if the team already has a stripeId (i.e. is a customer) let's use as a customer
stripeSession = await stripe.checkout.sessions.create({
...baseSessionConfig,
customer: team.stripeId,
@@ -153,7 +177,6 @@ export default async function handle(
...(!couponId && { allow_promotion_codes: true }),
});
} else {
// else initialize a new customer
stripeSession = await stripe.checkout.sessions.create({
...baseSessionConfig,
customer_email: userEmail ?? undefined,
@@ -177,7 +200,6 @@ export default async function handle(
return res.status(200).json(stripeSession);
} else {
// We only allow POST requests
res.setHeader("Allow", ["POST"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
+61 -39
View File
@@ -6,6 +6,10 @@ import React from "react";
import { useTeam } from "@/context/team-context";
import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import {
getPriceIdFromPlan,
getPerSeatPriceIdFromPlan,
} from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, Users2Icon, XIcon } from "lucide-react";
import { toast } from "sonner";
@@ -310,16 +314,16 @@ export default function UpgradeHolidayOfferPage() {
disabled={selectedPlan !== null}
onClick={() => {
setSelectedPlan(planOption);
const envKey =
process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
? "production"
: "test";
const plan = PLANS.find((p) => p.name === planOption);
if (!plan) return;
const priceId =
plan.price[period].priceIds[envKey][
isOldAccount ? "old" : "new"
];
const priceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const perSeatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
if (isCustomer && teamPlan !== "free") {
fetch(
@@ -331,6 +335,7 @@ export default function UpgradeHolidayOfferPage() {
},
body: JSON.stringify({
priceId,
perSeatPriceId,
upgradePlan: true,
applyYearlyDiscount: period === "yearly",
}),
@@ -353,10 +358,15 @@ export default function UpgradeHolidayOfferPage() {
setSelectedPlan(null);
});
} else {
const params = new URLSearchParams({
priceId: priceId!,
applyYearlyDiscount: String(period === "yearly"),
});
if (perSeatPriceId) {
params.set("perSeatPriceId", perSeatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === "yearly"}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
@@ -501,16 +511,16 @@ export default function UpgradeHolidayOfferPage() {
disabled={selectedPlan !== null}
onClick={() => {
setSelectedPlan(planOption);
const envKey =
process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
? "production"
: "test";
const plan = PLANS.find((p) => p.name === planOption);
if (!plan) return;
const priceId =
plan.price[period].priceIds[envKey][
isOldAccount ? "old" : "new"
];
const priceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const perSeatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
if (isCustomer && teamPlan !== "free") {
fetch(
@@ -522,6 +532,7 @@ export default function UpgradeHolidayOfferPage() {
},
body: JSON.stringify({
priceId,
perSeatPriceId,
upgradePlan: true,
applyYearlyDiscount: period === "yearly",
}),
@@ -544,10 +555,15 @@ export default function UpgradeHolidayOfferPage() {
setSelectedPlan(null);
});
} else {
const params = new URLSearchParams({
priceId: priceId!,
applyYearlyDiscount: String(period === "yearly"),
});
if (perSeatPriceId) {
params.set("perSeatPriceId", perSeatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === "yearly"}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
@@ -692,16 +708,16 @@ export default function UpgradeHolidayOfferPage() {
disabled={selectedPlan !== null}
onClick={() => {
setSelectedPlan(planOption);
const envKey =
process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
? "production"
: "test";
const plan = PLANS.find((p) => p.name === planOption);
if (!plan) return;
const priceId =
plan.price[period].priceIds[envKey][
isOldAccount ? "old" : "new"
];
const priceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const perSeatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
if (isCustomer && teamPlan !== "free") {
fetch(
@@ -713,6 +729,7 @@ export default function UpgradeHolidayOfferPage() {
},
body: JSON.stringify({
priceId,
perSeatPriceId,
upgradePlan: true,
applyYearlyDiscount: period === "yearly",
}),
@@ -735,10 +752,15 @@ export default function UpgradeHolidayOfferPage() {
setSelectedPlan(null);
});
} else {
const params = new URLSearchParams({
priceId: priceId!,
applyYearlyDiscount: String(period === "yearly"),
});
if (perSeatPriceId) {
params.set("perSeatPriceId", perSeatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${priceId}&applyYearlyDiscount=${period === "yearly"}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
+88 -35
View File
@@ -6,6 +6,10 @@ import React from "react";
import { useTeam } from "@/context/team-context";
import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import {
getPriceIdFromPlan,
getPerSeatPriceIdFromPlan,
} from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, Users2Icon, XIcon } from "lucide-react";
import { toast } from "sonner";
@@ -235,6 +239,17 @@ export default function UpgradePage() {
/month{period === "yearly" && ", billed annually"}
</span>
</div>
{PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat && (
<p className="text-sm text-gray-500 dark:text-white/60">
+
{
PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat?.amount
}
/mo per additional user
</p>
)}
<p className="mt-4 text-sm text-gray-600 dark:text-white">
{planFeatures.featureIntro}
</p>
@@ -289,18 +304,24 @@ export default function UpgradePage() {
setSelectedPlan(null);
});
} else {
const basePriceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const seatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const params = new URLSearchParams({
priceId: basePriceId!,
});
if (seatPriceId) {
params.set("perSeatPriceId", seatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${
PLANS.find((p) => p.name === planOption)!.price[
period
].priceIds[
process.env.NEXT_PUBLIC_VERCEL_ENV === "production"
? "production"
: "test"
][isOldAccount ? "old" : "new"]
}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
@@ -392,6 +413,17 @@ export default function UpgradePage() {
/month{period === "yearly" && ", billed annually"}
</span>
</div>
{PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat && (
<p className="text-sm text-gray-500 dark:text-white/60">
+
{
PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat?.amount
}
/mo per additional user
</p>
)}
<p className="mt-4 text-sm text-gray-600 dark:text-white">
{planFeatures.featureIntro}
</p>
@@ -446,19 +478,24 @@ export default function UpgradePage() {
setSelectedPlan(null);
});
} else {
const basePriceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const seatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const params = new URLSearchParams({
priceId: basePriceId!,
});
if (seatPriceId) {
params.set("perSeatPriceId", seatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${
PLANS.find((p) => p.name === planOption)!.price[
period
].priceIds[
process.env.NEXT_PUBLIC_VERCEL_ENV ===
"production"
? "production"
: "test"
][isOldAccount ? "old" : "new"]
}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {
@@ -549,6 +586,17 @@ export default function UpgradePage() {
/month{period === "yearly" && ", billed annually"}
</span>
</div>
{PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat && (
<p className="text-sm text-gray-500 dark:text-white/60">
+
{
PLANS.find((p) => p.name === planOption)?.price[period]
.perSeat?.amount
}
/mo per additional user
</p>
)}
<p className="mt-4 text-sm text-gray-600 dark:text-white">
{planFeatures.featureIntro}
</p>
@@ -603,19 +651,24 @@ export default function UpgradePage() {
setSelectedPlan(null);
});
} else {
const basePriceId = getPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const seatPriceId = getPerSeatPriceIdFromPlan({
planName: planOption,
period,
isOld: isOldAccount,
});
const params = new URLSearchParams({
priceId: basePriceId!,
});
if (seatPriceId) {
params.set("perSeatPriceId", seatPriceId);
}
fetch(
`/api/teams/${
teamInfo?.currentTeam?.id
}/billing/upgrade?priceId=${
PLANS.find((p) => p.name === planOption)!.price[
period
].priceIds[
process.env.NEXT_PUBLIC_VERCEL_ENV ===
"production"
? "production"
: "test"
][isOldAccount ? "old" : "new"]
}`,
`/api/teams/${teamInfo?.currentTeam?.id}/billing/upgrade?${params.toString()}`,
{
method: "POST",
headers: {