9 } from '@proton/payments';
11 import { CYCLE } from '../constants';
12 import type { Organization, Plan, PlansMap, StrictPlan, SubscriptionCheckResponse, User } from '../interfaces';
13 import { ChargebeeEnabled } from '../interfaces';
16 getSupportedB2BAddons,
22 import type { AggregatedPricing, PricingForCycles } from './subscription';
30 } from './subscription';
32 export const hasPlanIDs = (planIDs: PlanIDs) => Object.values(planIDs).some((quantity) => quantity > 0);
34 export const clearPlanIDs = (planIDs: PlanIDs): PlanIDs => {
35 return Object.entries(planIDs).reduce<PlanIDs>((acc, [planName, quantity = 0]) => {
39 acc[planName as keyof PlanIDs] = quantity;
45 * Transfer addons from one plan to another. In different plans, addons have different names
46 * and potentially different resource limits, so they must be converted manually using this function.
50 export const switchPlan = ({
58 planID?: PLANS | ADDON_NAMES;
59 organization?: Organization;
61 user: User | undefined;
63 if (planID === undefined) {
67 const newPlanIDs = { [planID]: 1 };
68 const supportedAddons = getSupportedAddons(newPlanIDs);
71 (Object.keys(supportedAddons) as ADDON_NAMES[]).forEach((addon) => {
72 const quantity = planIDs[addon as keyof PlanIDs];
75 newPlanIDs[addon] = quantity;
78 const plan = plans.find(({ Name }) => Name === planID);
80 // Transfer member addons
81 if (isMemberAddon(addon) && plan && organization) {
82 const memberAddon = plans.find(({ Name }) => Name === addon);
85 // Find out the smallest number of member addons that could accommodate the previously known usage
86 // of the resources. For example, if the user had 5 addresses, and each member addon only
87 // provides 1 additional address, then we would need to add 5 member addons to cover the previous
88 // usage. The maximum is chosen across all types of resources (space, addresses, VPNs, members,
89 // calendars) so as to ensure that the new plan covers the maximum usage of any single resource.
90 // In addition, we explicitely check how many members were used previously.
93 ((organization.UsedMembers > 1 ? organization.AssignedSpace : organization.UsedSpace) || 0) -
94 plan.MaxSpace; // AssignedSpace is the space assigned to members in the organization which count for addon transfer
95 const memberAddonsWithEnoughSpace =
96 diffSpace > 0 && memberAddon.MaxSpace ? Math.ceil(diffSpace / memberAddon.MaxSpace) : 0;
98 const diffAddresses = (organization.UsedAddresses || 0) - plan.MaxAddresses;
99 const memberAddonsWithEnoughAddresses =
100 diffAddresses > 0 && memberAddon.MaxAddresses
101 ? Math.ceil(diffAddresses / memberAddon.MaxAddresses)
104 const diffVPN = (organization.UsedVPN || 0) - plan.MaxVPN;
105 const memberAddonsWithEnoughVPNConnections =
106 diffVPN > 0 && memberAddon.MaxVPN ? Math.ceil(diffVPN / memberAddon.MaxVPN) : 0;
108 const diffMembers = (organization.UsedMembers || 0) - plan.MaxMembers;
109 const memberAddonsWithEnoughMembers =
110 diffMembers > 0 && memberAddon.MaxMembers ? Math.ceil(diffMembers / memberAddon.MaxMembers) : 0;
112 const diffCalendars = (organization.UsedCalendars || 0) - plan.MaxCalendars;
113 const memberAddonsWithEnoughCalendars =
114 diffCalendars > 0 && memberAddon.MaxCalendars
115 ? Math.ceil(diffCalendars / memberAddon.MaxCalendars)
118 // count all available member addons in the new planIDs selection
119 let memberAddons = 0;
120 for (const addonName of Object.values(ADDON_NAMES)) {
121 if (isMemberAddon(addonName)) {
122 memberAddons += planIDs[addonName] ?? 0;
126 newPlanIDs[addon] = Math.max(
127 memberAddonsWithEnoughSpace,
128 memberAddonsWithEnoughAddresses,
129 memberAddonsWithEnoughVPNConnections,
130 memberAddonsWithEnoughMembers,
131 memberAddonsWithEnoughCalendars,
137 // Transfer domain addons
138 if (isDomainAddon(addon) && plan && organization) {
139 const domainAddon = plans.find(({ Name }) => Name === addon);
140 const diffDomains = (organization.UsedDomains || 0) - plan.MaxDomains;
143 newPlanIDs[addon] = Math.max(
144 diffDomains > 0 && domainAddon.MaxDomains ? Math.ceil(diffDomains / domainAddon.MaxDomains) : 0,
145 (planIDs[ADDON_NAMES.DOMAIN_ENTERPRISE] || 0) + (planIDs[ADDON_NAMES.DOMAIN_BUNDLE_PRO] || 0)
150 // In case if we have inhouse Visionary user with non-zero UsedAI and they can't use Chargebee yet
151 // (e.g. because of Business flag) then we forbid transfering Scribe addons, because it will go to v4 payments
152 // which don't support scribe.
153 const canTransferScribe = user?.ChargebeeUser !== ChargebeeEnabled.INHOUSE_FORCED;
155 if (isScribeAddon(addon) && plan && organization && canTransferScribe) {
156 const gptAddon = plans.find(({ Name }) => Name === addon);
157 const diffAIs = (organization.UsedAI || 0) - getMaxValue(plan, 'MaxAI');
160 const gptAddonsWithEnoughSeats =
161 diffAIs > 0 && getMaxValue(gptAddon, 'MaxAI')
162 ? Math.ceil(diffAIs / getMaxValue(gptAddon, 'MaxAI'))
165 // let count all available GPT addons in the new planIDs selection
167 for (const addonName of Object.values(ADDON_NAMES)) {
168 if (isScribeAddon(addonName)) {
169 gptAddons += planIDs[addonName] ?? 0;
173 newPlanIDs[addon] = Math.max(gptAddonsWithEnoughSeats, gptAddons);
177 if (isIpAddon(addon) && plan && organization) {
178 // cycle and currency don't matter in this case
179 const currentPlan = new SelectedPlan(planIDs, plans, CYCLE.MONTHLY, DEFAULT_CURRENCY);
180 const newPlan = new SelectedPlan(newPlanIDs, plans, CYCLE.MONTHLY, DEFAULT_CURRENCY);
182 const totalIPs = currentPlan.getTotalIPs();
183 const ipAddonsRequired = totalIPs - newPlan.getIncludedIPs();
185 newPlanIDs[addon] = ipAddonsRequired;
189 return clearPlanIDs(newPlanIDs);
192 export const setQuantity = (planIDs: PlanIDs, planID: PLANS | ADDON_NAMES, newQuantity: number) => {
193 const { [planID]: removedPlan, ...restPlanIDs } = planIDs;
194 if (!newQuantity || newQuantity <= 0) {
199 [planID]: newQuantity,
203 export const supportB2BAddons = (planIDs: PlanIDs) => {
204 const supportedAddons = getSupportedB2BAddons(planIDs);
205 return !!Object.keys(supportedAddons).length;
208 export const getPlanFromPlanIDs = (plansMap: PlansMap, planIDs: PlanIDs = {}): StrictPlan | undefined => {
209 const planID = Object.keys(planIDs).find((planID): planID is keyof PlansMap => {
210 const type = plansMap[planID as keyof PlansMap]?.Type;
211 return type === PLAN_TYPES.PLAN || type === PLAN_TYPES.PRODUCT;
214 return plansMap[planID] as StrictPlan;
218 export const getPlanCurrencyFromPlanIDs = (plansMap: PlansMap, planIDs: PlanIDs = {}): Currency | undefined => {
219 const plan = getPlanFromPlanIDs(plansMap, planIDs);
220 return plan?.Currency;
223 export function getPlanNameFromIDs(planIDs: PlanIDs): PLANS | undefined {
224 return Object.values(PLANS).find((key) => {
225 // If the planIDs object has non-zero value for the plan, then it exists.
226 // There can be at most 1 plan, and others are addons.
227 const planNumber = planIDs[key as PLANS] ?? 0;
228 return planNumber > 0;
232 export function getPlanFromIDs(planIDs: PlanIDs, plansMap: PlansMap): Plan | undefined {
233 const planName = getPlanNameFromIDs(planIDs);
234 return planName ? plansMap[planName] : undefined;
237 export const getPricingFromPlanIDs = (planIDs: PlanIDs, plansMap: PlansMap): AggregatedPricing => {
243 [CYCLE.TWO_YEARS]: 0,
248 return Object.entries(planIDs).reduce<AggregatedPricing>(
249 (acc, [planName, quantity]) => {
250 const plan = plansMap[planName as keyof PlansMap];
255 const members = getPlanMembers(plan, quantity);
256 acc.membersNumber += members;
258 const add = (target: PricingForCycles, cycle: CYCLE) => {
259 const price = getPricePerCycle(plan, cycle);
261 target[cycle] += quantity * price;
265 const addMembersPricing = (target: PricingForCycles, cycle: CYCLE) => {
266 const price = getPricePerMember(plan, cycle);
268 target[cycle] += members * price;
272 allCycles.forEach((cycle) => {
277 allCycles.forEach((cycle) => {
278 addMembersPricing(acc.members, cycle);
282 if (plan.Type === PLAN_TYPES.PLAN) {
283 allCycles.forEach((cycle) => {
284 add(acc.plans, cycle);
287 acc.defaultMonthlyPriceWithoutAddons += quantity * (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
290 const defaultMonthly = plan.DefaultPricing?.[CYCLE.MONTHLY] ?? 0;
291 const monthly = getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0;
293 // Offers might affect Pricing both ways, increase and decrease.
294 // So if the Pricing increases, then we don't want to use the lower DefaultPricing as basis
295 // for discount calculations
296 const price = Math.max(defaultMonthly, monthly);
298 acc.defaultMonthlyPrice += quantity * price;
303 defaultMonthlyPrice: 0,
304 defaultMonthlyPriceWithoutAddons: 0,
317 export type PricingMode = 'all' | 'plans';
319 export type TotalPricing = ReturnType<typeof getTotalFromPricing>;
321 // todo: replace signature with SelectedPlan only
322 export const getTotalFromPricing = (
323 pricing: AggregatedPricing,
325 mode: PricingMode = 'all',
326 additionalCheckResults?: SubscriptionCheckResponse[],
327 selectedPlan?: SelectedPlan
329 type CheckedPrices = Record<
333 CouponDiscount: number;
337 const checkedPrices =
338 additionalCheckResults?.reduce((acc, { CouponDiscount = 0, Cycle, Amount }) => {
345 }, {} as CheckedPrices) ?? ({} as CheckedPrices);
347 const { defaultMonthlyPrice, defaultMonthlyPriceWithoutAddons } = pricing;
349 const total = checkedPrices[cycle]?.Amount ?? pricing[mode][cycle];
351 let couponDiscount = checkedPrices[cycle]?.CouponDiscount ?? 0;
352 if (couponDiscount < 0) {
353 couponDiscount = -couponDiscount;
356 const discountedTotal = total - couponDiscount;
357 const totalPerMonth = discountedTotal / cycle;
359 const price = mode === 'all' ? defaultMonthlyPrice : defaultMonthlyPriceWithoutAddons;
360 const totalNoDiscount = price * cycle;
361 const discount = cycle === CYCLE.MONTHLY ? 0 : totalNoDiscount - discountedTotal;
363 const membersPricePerMonthWithoutDiscount = Math.floor(pricing.members[cycle] / cycle);
364 const memberShare = membersPricePerMonthWithoutDiscount / (total / cycle);
365 const membersDiscount = Math.floor(couponDiscount * memberShare);
366 const discountPerUserPerMonth = membersDiscount / pricing.membersNumber / cycle;
367 const perUserPerMonth = membersPricePerMonthWithoutDiscount / pricing.membersNumber - discountPerUserPerMonth;
369 const viewPricePerMonth = selectedPlan?.isB2BPlan() ? perUserPerMonth : totalPerMonth;
373 discountPercentage: discount > 0 ? Math.round((discount / totalNoDiscount) * 100) : 0,
376 totalNoDiscountPerMonth: totalNoDiscount / cycle,
382 export type TotalPricings = {
383 [key in CYCLE]: TotalPricing;
386 export function getTotals(
389 additionalCheckResults: SubscriptionCheckResponse[],
391 selectedPlan?: SelectedPlan
393 const pricing = getPricingFromPlanIDs(planIDs, plansMap);
395 return allCycles.reduce<{ [key in CYCLE]: TotalPricing }>((acc, cycle) => {
396 acc[cycle] = getTotalFromPricing(pricing, cycle, mode, additionalCheckResults, selectedPlan);
401 export function planIDsPositiveDifference(oldPlanIDs: PlanIDs, newPlanIDs: PlanIDs): PlanIDs {
402 if (!oldPlanIDs || !newPlanIDs) {
406 const increasedPlanIDs: PlanIDs = {};
408 for (const key of Object.keys(newPlanIDs) as (keyof PlanIDs)[]) {
409 const newQuantity = newPlanIDs[key] ?? 0;
410 const oldQuantity = oldPlanIDs[key] ?? 0;
412 const increase = newQuantity - oldQuantity;
414 increasedPlanIDs[key] = increase;
418 return increasedPlanIDs;
421 export function isLifetimePlanSelected(planIDs: PlanIDs): boolean {
422 const planName = getPlanNameFromIDs(planIDs);
423 return isLifetimePlan(planName);