Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / planIDs.ts
blob29b34fb4bd138aa3835302e62c38269a64c896f2
1 import {
2     ADDON_NAMES,
3     type Currency,
4     DEFAULT_CURRENCY,
5     PLANS,
6     PLAN_TYPES,
7     type PlanIDs,
8     SelectedPlan,
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';
14 import {
15     getSupportedAddons,
16     getSupportedB2BAddons,
17     isDomainAddon,
18     isIpAddon,
19     isMemberAddon,
20     isScribeAddon,
21 } from './addons';
22 import type { AggregatedPricing, PricingForCycles } from './subscription';
23 import {
24     allCycles,
25     getMaxValue,
26     getPlanMembers,
27     getPricePerCycle,
28     getPricePerMember,
29     isLifetimePlan,
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]) => {
36         if (quantity <= 0) {
37             return acc;
38         }
39         acc[planName as keyof PlanIDs] = quantity;
40         return acc;
41     }, {});
44 /**
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.
47  *
48  * @returns
49  */
50 export const switchPlan = ({
51     planIDs,
52     planID,
53     organization,
54     plans,
55     user,
56 }: {
57     planIDs: PlanIDs;
58     planID?: PLANS | ADDON_NAMES;
59     organization?: Organization;
60     plans: Plan[];
61     user: User | undefined;
62 }): PlanIDs => {
63     if (planID === undefined) {
64         return {};
65     }
67     const newPlanIDs = { [planID]: 1 };
68     const supportedAddons = getSupportedAddons(newPlanIDs);
70     // Transfer addons
71     (Object.keys(supportedAddons) as ADDON_NAMES[]).forEach((addon) => {
72         const quantity = planIDs[addon as keyof PlanIDs];
74         if (quantity) {
75             newPlanIDs[addon] = quantity;
76         }
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);
84             if (memberAddon) {
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.
92                 const diffSpace =
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)
102                         : 0;
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)
116                         : 0;
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;
123                     }
124                 }
126                 newPlanIDs[addon] = Math.max(
127                     memberAddonsWithEnoughSpace,
128                     memberAddonsWithEnoughAddresses,
129                     memberAddonsWithEnoughVPNConnections,
130                     memberAddonsWithEnoughMembers,
131                     memberAddonsWithEnoughCalendars,
132                     memberAddons
133                 );
134             }
135         }
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;
142             if (domainAddon) {
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)
146                 );
147             }
148         }
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');
159             if (gptAddon) {
160                 const gptAddonsWithEnoughSeats =
161                     diffAIs > 0 && getMaxValue(gptAddon, 'MaxAI')
162                         ? Math.ceil(diffAIs / getMaxValue(gptAddon, 'MaxAI'))
163                         : 0;
165                 // let count all available GPT addons in the new planIDs selection
166                 let gptAddons = 0;
167                 for (const addonName of Object.values(ADDON_NAMES)) {
168                     if (isScribeAddon(addonName)) {
169                         gptAddons += planIDs[addonName] ?? 0;
170                     }
171                 }
173                 newPlanIDs[addon] = Math.max(gptAddonsWithEnoughSeats, gptAddons);
174             }
175         }
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;
186         }
187     });
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) {
195         return restPlanIDs;
196     }
197     return {
198         ...restPlanIDs,
199         [planID]: newQuantity,
200     };
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;
212     });
213     if (planID) {
214         return plansMap[planID] as StrictPlan;
215     }
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;
229     });
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 => {
238     const initial = {
239         [CYCLE.MONTHLY]: 0,
240         [CYCLE.YEARLY]: 0,
241         [CYCLE.THREE]: 0,
242         [CYCLE.EIGHTEEN]: 0,
243         [CYCLE.TWO_YEARS]: 0,
244         [CYCLE.FIFTEEN]: 0,
245         [CYCLE.THIRTY]: 0,
246     };
248     return Object.entries(planIDs).reduce<AggregatedPricing>(
249         (acc, [planName, quantity]) => {
250             const plan = plansMap[planName as keyof PlansMap];
251             if (!plan) {
252                 return acc;
253             }
255             const members = getPlanMembers(plan, quantity);
256             acc.membersNumber += members;
258             const add = (target: PricingForCycles, cycle: CYCLE) => {
259                 const price = getPricePerCycle(plan, cycle);
260                 if (price) {
261                     target[cycle] += quantity * price;
262                 }
263             };
265             const addMembersPricing = (target: PricingForCycles, cycle: CYCLE) => {
266                 const price = getPricePerMember(plan, cycle);
267                 if (price) {
268                     target[cycle] += members * price;
269                 }
270             };
272             allCycles.forEach((cycle) => {
273                 add(acc.all, cycle);
274             });
276             if (members !== 0) {
277                 allCycles.forEach((cycle) => {
278                     addMembersPricing(acc.members, cycle);
279                 });
280             }
282             if (plan.Type === PLAN_TYPES.PLAN) {
283                 allCycles.forEach((cycle) => {
284                     add(acc.plans, cycle);
285                 });
287                 acc.defaultMonthlyPriceWithoutAddons += quantity * (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
288             }
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;
300             return acc;
301         },
302         {
303             defaultMonthlyPrice: 0,
304             defaultMonthlyPriceWithoutAddons: 0,
305             all: { ...initial },
306             members: {
307                 ...initial,
308             },
309             plans: {
310                 ...initial,
311             },
312             membersNumber: 0,
313         }
314     );
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,
324     cycle: CYCLE,
325     mode: PricingMode = 'all',
326     additionalCheckResults?: SubscriptionCheckResponse[],
327     selectedPlan?: SelectedPlan
328 ) => {
329     type CheckedPrices = Record<
330         CYCLE,
331         {
332             Amount: number;
333             CouponDiscount: number;
334         }
335     >;
337     const checkedPrices =
338         additionalCheckResults?.reduce((acc, { CouponDiscount = 0, Cycle, Amount }) => {
339             acc[Cycle] = {
340                 Amount,
341                 CouponDiscount,
342             };
344             return acc;
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;
354     }
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;
371     return {
372         discount,
373         discountPercentage: discount > 0 ? Math.round((discount / totalNoDiscount) * 100) : 0,
374         discountedTotal,
375         totalPerMonth,
376         totalNoDiscountPerMonth: totalNoDiscount / cycle,
377         perUserPerMonth,
378         viewPricePerMonth,
379     };
382 export type TotalPricings = {
383     [key in CYCLE]: TotalPricing;
386 export function getTotals(
387     planIDs: PlanIDs,
388     plansMap: PlansMap,
389     additionalCheckResults: SubscriptionCheckResponse[],
390     mode?: PricingMode,
391     selectedPlan?: SelectedPlan
392 ): TotalPricings {
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);
397         return acc;
398     }, {} as any);
401 export function planIDsPositiveDifference(oldPlanIDs: PlanIDs, newPlanIDs: PlanIDs): PlanIDs {
402     if (!oldPlanIDs || !newPlanIDs) {
403         return {};
404     }
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;
413         if (increase > 0) {
414             increasedPlanIDs[key] = increase;
415         }
416     }
418     return increasedPlanIDs;
421 export function isLifetimePlanSelected(planIDs: PlanIDs): boolean {
422     const planName = getPlanNameFromIDs(planIDs);
423     return isLifetimePlan(planName);