Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21308e6c8b |
@@ -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>
|
||||
|
||||
@@ -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}` };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user