Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / planIDs.ts
blob4b3c7afa712d873a3f7b232d0a62f5723956eb80
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]) => {
14         if (quantity <= 0) {
15             return acc;
16         }
17         acc[planName as keyof PlanIDs] = quantity;
18         return acc;
19     }, {});
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) {
27             return plan;
28         }
29     }
31     return null;
34 /**
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.
37  *
38  * @returns
39  */
40 export const switchPlan = ({
41     planIDs,
42     planID,
43     organization,
44     plans,
45 }: {
46     planIDs: PlanIDs;
47     planID?: PLANS | ADDON_NAMES;
48     organization?: Organization;
49     plans: Plan[];
50 }): PlanIDs => {
51     if (planID === undefined) {
52         return {};
53     }
55     const newPlanIDs = { [planID]: 1 };
56     const supportedAddons = getSupportedAddons(newPlanIDs);
58     // Transfer addons
59     (Object.keys(supportedAddons) as ADDON_NAMES[]).forEach((addon) => {
60         const quantity = planIDs[addon as keyof PlanIDs];
62         if (quantity) {
63             newPlanIDs[addon] = quantity;
64         }
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);
72             if (memberAddon) {
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.
80                 const diffSpace =
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)
90                         : 0;
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)
104                         : 0;
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;
111                     }
112                 }
114                 newPlanIDs[addon] = Math.max(
115                     memberAddonsWithEnoughSpace,
116                     memberAddonsWithEnoughAddresses,
117                     memberAddonsWithEnoughVPNConnections,
118                     memberAddonsWithEnoughMembers,
119                     memberAddonsWithEnoughCalendars,
120                     memberAddons
121                 );
122             }
123         }
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;
130             if (domainAddon) {
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)
134                 );
135             }
136         }
138         if (isScribeAddon(addon) && plan && organization) {
139             const gptAddon = plans.find(({ Name }) => Name === addon);
140             const diffAIs = (organization.UsedAI || 0) - getMaxValue(plan, 'MaxAI');
142             if (gptAddon) {
143                 const gptAddonsWithEnoughSeats =
144                     diffAIs > 0 && getMaxValue(gptAddon, 'MaxAI')
145                         ? Math.ceil(diffAIs / getMaxValue(gptAddon, 'MaxAI'))
146                         : 0;
148                 // let count all available GPT addons in the new planIDs selection
149                 let gptAddons = 0;
150                 for (const addonName of Object.values(ADDON_NAMES)) {
151                     if (isScribeAddon(addonName)) {
152                         gptAddons += planIDs[addonName] ?? 0;
153                     }
154                 }
156                 newPlanIDs[addon] = Math.max(gptAddonsWithEnoughSeats, gptAddons);
157             }
158         }
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.
162     });
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) {
170         return restPlanIDs;
171     }
172     return {
173         ...restPlanIDs,
174         [planID]: newQuantity,
175     };
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;
186     });
187     if (planID) {
188         return plansMap[planID] as Plan & { Name: PLANS };
189     }
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;
201     });
203 export const getPricingFromPlanIDs = (planIDs: PlanIDs, plansMap: PlansMap): AggregatedPricing => {
204     const initial = {
205         [CYCLE.MONTHLY]: 0,
206         [CYCLE.YEARLY]: 0,
207         [CYCLE.THREE]: 0,
208         [CYCLE.EIGHTEEN]: 0,
209         [CYCLE.TWO_YEARS]: 0,
210         [CYCLE.FIFTEEN]: 0,
211         [CYCLE.THIRTY]: 0,
212     };
214     return Object.entries(planIDs).reduce<AggregatedPricing>(
215         (acc, [planName, quantity]) => {
216             const plan = plansMap[planName as keyof PlansMap];
217             if (!plan) {
218                 return acc;
219             }
221             const members = getPlanMembers(plan, quantity);
222             acc.membersNumber += members;
224             const add = (target: PricingForCycles, cycle: CYCLE) => {
225                 const price = getPricePerCycle(plan, cycle);
226                 if (price) {
227                     target[cycle] += quantity * price;
228                 }
229             };
231             const addMembersPricing = (target: PricingForCycles, cycle: CYCLE) => {
232                 const price = getPricePerMember(plan, cycle);
233                 if (price) {
234                     target[cycle] += members * price;
235                 }
236             };
238             allCycles.forEach((cycle) => {
239                 add(acc.all, cycle);
240             });
242             if (members !== 0) {
243                 allCycles.forEach((cycle) => {
244                     addMembersPricing(acc.members, cycle);
245                 });
246             }
248             if (plan.Type === PLAN_TYPES.PLAN) {
249                 allCycles.forEach((cycle) => {
250                     add(acc.plans, cycle);
251                 });
253                 acc.defaultMonthlyPriceWithoutAddons += quantity * (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
254             }
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;
266             return acc;
267         },
268         {
269             defaultMonthlyPrice: 0,
270             defaultMonthlyPriceWithoutAddons: 0,
271             all: { ...initial },
272             members: {
273                 ...initial,
274             },
275             plans: {
276                 ...initial,
277             },
278             membersNumber: 0,
279         }
280     );
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,
290     cycle: CYCLE,
291     mode: PricingMode = 'all',
292     additionalCheckResults?: SubscriptionCheckResponse[],
293     selectedPlan?: SelectedPlan
294 ) => {
295     type CheckedPrices = Record<
296         CYCLE,
297         {
298             Amount: number;
299             CouponDiscount: number;
300         }
301     >;
303     const checkedPrices =
304         additionalCheckResults?.reduce((acc, { CouponDiscount = 0, Cycle, Amount }) => {
305             acc[Cycle] = {
306                 Amount,
307                 CouponDiscount,
308             };
310             return acc;
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;
320     }
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;
337     return {
338         discount,
339         discountPercentage: discount > 0 ? Math.round((discount / totalNoDiscount) * 100) : 0,
340         discountedTotal,
341         totalPerMonth,
342         totalNoDiscountPerMonth: totalNoDiscount / cycle,
343         perUserPerMonth,
344         viewPricePerMonth,
345     };
348 export type TotalPricings = {
349     [key in CYCLE]: TotalPricing;
352 export function getTotals(
353     planIDs: PlanIDs,
354     plansMap: PlansMap,
355     additionalCheckResults: SubscriptionCheckResponse[],
356     mode?: PricingMode,
357     selectedPlan?: SelectedPlan
358 ): TotalPricings {
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);
363         return acc;
364     }, {} as any);
367 export function planIDsPositiveDifference(oldPlanIDs: PlanIDs, newPlanIDs: PlanIDs): PlanIDs {
368     if (!oldPlanIDs || !newPlanIDs) {
369         return {};
370     }
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;
379         if (increase > 0) {
380             increasedPlanIDs[key] = increase;
381         }
382     }
384     return increasedPlanIDs;