1 import type { SelectedPlan } from '@proton/components/payments/core';
3 import { ADDON_NAMES, CYCLE, PLANS, PLAN_TYPES } from '../constants';
4 import type { Organization, Plan, PlanIDs, PlansMap, SubscriptionCheckResponse } from '../interfaces';
5 import { getMaxValue } from '../interfaces';
6 import { getSupportedAddons, getSupportedB2BAddons, isDomainAddon, isMemberAddon, isScribeAddon } from './addons';
7 import type { AggregatedPricing, PricingForCycles } from './subscription';
8 import { allCycles, getPlanMembers, getPricePerCycle, getPricePerMember } from './subscription';
10 export const hasPlanIDs = (planIDs: PlanIDs) => Object.values(planIDs).some((quantity) => quantity > 0);
12 export const clearPlanIDs = (planIDs: PlanIDs): PlanIDs => {
13 return Object.entries(planIDs).reduce<PlanIDs>((acc, [planName, quantity = 0]) => {
17 acc[planName as keyof PlanIDs] = quantity;
22 export const getPlanFromCheckout = (planIDs: PlanIDs, plansMap: PlansMap): Plan | null => {
23 const planNames = Object.keys(planIDs) as (keyof PlanIDs)[];
24 for (const planName of planNames) {
25 const plan = plansMap[planName];
26 if (plan?.Type === PLAN_TYPES.PLAN) {
35 * Transfer addons from one plan to another. In different plans, addons have different names
36 * and potentially different resource limits, so they must be converted manually using this function.
40 export const switchPlan = ({
47 planID?: PLANS | ADDON_NAMES;
48 organization?: Organization;
51 if (planID === undefined) {
55 const newPlanIDs = { [planID]: 1 };
56 const supportedAddons = getSupportedAddons(newPlanIDs);
59 (Object.keys(supportedAddons) as ADDON_NAMES[]).forEach((addon) => {
60 const quantity = planIDs[addon as keyof PlanIDs];
63 newPlanIDs[addon] = quantity;
66 const plan = plans.find(({ Name }) => Name === planID);
68 // Transfer member addons
69 if (isMemberAddon(addon) && plan && organization) {
70 const memberAddon = plans.find(({ Name }) => Name === addon);
73 // Find out the smallest number of member addons that could accommodate the previously known usage
74 // of the resources. For example, if the user had 5 addresses, and each member addon only
75 // provides 1 additional address, then we would need to add 5 member addons to cover the previous
76 // usage. The maximum is chosen across all types of resources (space, addresses, VPNs, members,
77 // calendars) so as to ensure that the new plan covers the maximum usage of any single resource.
78 // In addition, we explicitely check how many members were used previously.
81 ((organization.UsedMembers > 1 ? organization.AssignedSpace : organization.UsedSpace) || 0) -
82 plan.MaxSpace; // AssignedSpace is the space assigned to members in the organization which count for addon transfer
83 const memberAddonsWithEnoughSpace =
84 diffSpace > 0 && memberAddon.MaxSpace ? Math.ceil(diffSpace / memberAddon.MaxSpace) : 0;
86 const diffAddresses = (organization.UsedAddresses || 0) - plan.MaxAddresses;
87 const memberAddonsWithEnoughAddresses =
88 diffAddresses > 0 && memberAddon.MaxAddresses
89 ? Math.ceil(diffAddresses / memberAddon.MaxAddresses)
92 const diffVPN = (organization.UsedVPN || 0) - plan.MaxVPN;
93 const memberAddonsWithEnoughVPNConnections =
94 diffVPN > 0 && memberAddon.MaxVPN ? Math.ceil(diffVPN / memberAddon.MaxVPN) : 0;
96 const diffMembers = (organization.UsedMembers || 0) - plan.MaxMembers;
97 const memberAddonsWithEnoughMembers =
98 diffMembers > 0 && memberAddon.MaxMembers ? Math.ceil(diffMembers / memberAddon.MaxMembers) : 0;
100 const diffCalendars = (organization.UsedCalendars || 0) - plan.MaxCalendars;
101 const memberAddonsWithEnoughCalendars =
102 diffCalendars > 0 && memberAddon.MaxCalendars
103 ? Math.ceil(diffCalendars / memberAddon.MaxCalendars)
106 // count all available member addons in the new planIDs selection
107 let memberAddons = 0;
108 for (const addonName of Object.values(ADDON_NAMES)) {
109 if (isMemberAddon(addonName)) {
110 memberAddons += planIDs[addonName] ?? 0;
114 newPlanIDs[addon] = Math.max(
115 memberAddonsWithEnoughSpace,
116 memberAddonsWithEnoughAddresses,
117 memberAddonsWithEnoughVPNConnections,
118 memberAddonsWithEnoughMembers,
119 memberAddonsWithEnoughCalendars,
125 // Transfer domain addons
126 if (isDomainAddon(addon) && plan && organization) {
127 const domainAddon = plans.find(({ Name }) => Name === addon);
128 const diffDomains = (organization.UsedDomains || 0) - plan.MaxDomains;
131 newPlanIDs[addon] = Math.max(
132 diffDomains > 0 && domainAddon.MaxDomains ? Math.ceil(diffDomains / domainAddon.MaxDomains) : 0,
133 (planIDs[ADDON_NAMES.DOMAIN_ENTERPRISE] || 0) + (planIDs[ADDON_NAMES.DOMAIN_BUNDLE_PRO] || 0)
138 if (isScribeAddon(addon) && plan && organization) {
139 const gptAddon = plans.find(({ Name }) => Name === addon);
140 const diffAIs = (organization.UsedAI || 0) - getMaxValue(plan, 'MaxAI');
143 const gptAddonsWithEnoughSeats =
144 diffAIs > 0 && getMaxValue(gptAddon, 'MaxAI')
145 ? Math.ceil(diffAIs / getMaxValue(gptAddon, 'MaxAI'))
148 // let count all available GPT addons in the new planIDs selection
150 for (const addonName of Object.values(ADDON_NAMES)) {
151 if (isScribeAddon(addonName)) {
152 gptAddons += planIDs[addonName] ?? 0;
156 newPlanIDs[addon] = Math.max(gptAddonsWithEnoughSeats, gptAddons);
160 // '1ip' case remains unhandled. We currently have only one plan with an IP addon, so for now it is not transferable.
161 // When/if we have the other plans with the same addon type, then it must be handled here.
164 return clearPlanIDs(newPlanIDs);
167 export const setQuantity = (planIDs: PlanIDs, planID: PLANS | ADDON_NAMES, newQuantity: number) => {
168 const { [planID]: removedPlan, ...restPlanIDs } = planIDs;
169 if (!newQuantity || newQuantity <= 0) {
174 [planID]: newQuantity,
178 export const supportB2BAddons = (planIDs: PlanIDs) => {
179 const supportedAddons = getSupportedB2BAddons(planIDs);
180 return !!Object.keys(supportedAddons).length;
183 export const getPlanFromPlanIDs = (plansMap: PlansMap, planIDs: PlanIDs = {}): (Plan & { Name: PLANS }) | undefined => {
184 const planID = Object.keys(planIDs).find((planID): planID is keyof PlansMap => {
185 return plansMap[planID as keyof PlansMap]?.Type === PLAN_TYPES.PLAN;
188 return plansMap[planID] as Plan & { Name: PLANS };
191 export function getPlanNameFromIDs(planIDs: PlanIDs): PLANS | undefined {
192 const availableKeys = Object.keys(planIDs);
193 return Object.values(PLANS).find((value) => availableKeys.includes(value));
195 export function getPlanFromIds(planIDs: PlanIDs): PLANS | undefined {
196 return Object.values(PLANS).find((key) => {
197 // If the planIDs object has non-zero value for the plan, then it exists.
198 // There can be at most 1 plan, and others are addons.
199 const planNumber = planIDs[key as PLANS] ?? 0;
200 return planNumber > 0;
203 export const getPricingFromPlanIDs = (planIDs: PlanIDs, plansMap: PlansMap): AggregatedPricing => {
209 [CYCLE.TWO_YEARS]: 0,
214 return Object.entries(planIDs).reduce<AggregatedPricing>(
215 (acc, [planName, quantity]) => {
216 const plan = plansMap[planName as keyof PlansMap];
221 const members = getPlanMembers(plan, quantity);
222 acc.membersNumber += members;
224 const add = (target: PricingForCycles, cycle: CYCLE) => {
225 const price = getPricePerCycle(plan, cycle);
227 target[cycle] += quantity * price;
231 const addMembersPricing = (target: PricingForCycles, cycle: CYCLE) => {
232 const price = getPricePerMember(plan, cycle);
234 target[cycle] += members * price;
238 allCycles.forEach((cycle) => {
243 allCycles.forEach((cycle) => {
244 addMembersPricing(acc.members, cycle);
248 if (plan.Type === PLAN_TYPES.PLAN) {
249 allCycles.forEach((cycle) => {
250 add(acc.plans, cycle);
253 acc.defaultMonthlyPriceWithoutAddons += quantity * (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
256 const defaultMonthly = plan.DefaultPricing?.[CYCLE.MONTHLY] ?? 0;
257 const monthly = getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0;
259 // Offers might affect Pricing both ways, increase and decrease.
260 // So if the Pricing increases, then we don't want to use the lower DefaultPricing as basis
261 // for discount calculations
262 const price = Math.max(defaultMonthly, monthly);
264 acc.defaultMonthlyPrice += quantity * price;
269 defaultMonthlyPrice: 0,
270 defaultMonthlyPriceWithoutAddons: 0,
283 export type PricingMode = 'all' | 'plans';
285 export type TotalPricing = ReturnType<typeof getTotalFromPricing>;
287 // todo: replace signature with SelectedPlan only
288 export const getTotalFromPricing = (
289 pricing: AggregatedPricing,
291 mode: PricingMode = 'all',
292 additionalCheckResults?: SubscriptionCheckResponse[],
293 selectedPlan?: SelectedPlan
295 type CheckedPrices = Record<
299 CouponDiscount: number;
303 const checkedPrices =
304 additionalCheckResults?.reduce((acc, { CouponDiscount = 0, Cycle, Amount }) => {
311 }, {} as CheckedPrices) ?? ({} as CheckedPrices);
313 const { defaultMonthlyPrice, defaultMonthlyPriceWithoutAddons } = pricing;
315 const total = checkedPrices[cycle]?.Amount ?? pricing[mode][cycle];
317 let couponDiscount = checkedPrices[cycle]?.CouponDiscount ?? 0;
318 if (couponDiscount < 0) {
319 couponDiscount = -couponDiscount;
322 const discountedTotal = total - couponDiscount;
323 const totalPerMonth = discountedTotal / cycle;
325 const price = mode === 'all' ? defaultMonthlyPrice : defaultMonthlyPriceWithoutAddons;
326 const totalNoDiscount = price * cycle;
327 const discount = cycle === CYCLE.MONTHLY ? 0 : totalNoDiscount - discountedTotal;
329 const membersPricePerMonthWithoutDiscount = Math.floor(pricing.members[cycle] / cycle);
330 const memberShare = membersPricePerMonthWithoutDiscount / (total / cycle);
331 const membersDiscount = Math.floor(couponDiscount * memberShare);
332 const discountPerUserPerMonth = membersDiscount / pricing.membersNumber / cycle;
333 const perUserPerMonth = membersPricePerMonthWithoutDiscount / pricing.membersNumber - discountPerUserPerMonth;
335 const viewPricePerMonth = selectedPlan?.isB2BPlan() ? perUserPerMonth : totalPerMonth;
339 discountPercentage: discount > 0 ? Math.round((discount / totalNoDiscount) * 100) : 0,
342 totalNoDiscountPerMonth: totalNoDiscount / cycle,
348 export type TotalPricings = {
349 [key in CYCLE]: TotalPricing;
352 export function getTotals(
355 additionalCheckResults: SubscriptionCheckResponse[],
357 selectedPlan?: SelectedPlan
359 const pricing = getPricingFromPlanIDs(planIDs, plansMap);
361 return allCycles.reduce<{ [key in CYCLE]: TotalPricing }>((acc, cycle) => {
362 acc[cycle] = getTotalFromPricing(pricing, cycle, mode, additionalCheckResults, selectedPlan);
367 export function planIDsPositiveDifference(oldPlanIDs: PlanIDs, newPlanIDs: PlanIDs): PlanIDs {
368 if (!oldPlanIDs || !newPlanIDs) {
372 const increasedPlanIDs: PlanIDs = {};
374 for (const key of Object.keys(newPlanIDs) as (keyof PlanIDs)[]) {
375 const newQuantity = newPlanIDs[key] ?? 0;
376 const oldQuantity = oldPlanIDs[key] ?? 0;
378 const increase = newQuantity - oldQuantity;
380 increasedPlanIDs[key] = increase;
384 return increasedPlanIDs;