Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / checkout.ts
blob966c9705c07f53ec984169a11efacbbdac5df052
1 import { c, msgid } from 'ttag';
3 import {
4     ADDON_NAMES,
5     type Currency,
6     type MaxKeys,
7     PLANS,
8     PLAN_NAMES,
9     PLAN_TYPES,
10     type PlanIDs,
11 } from '@proton/payments';
13 import { CYCLE, DEFAULT_CYCLE, VPN_PASS_PROMOTION_COUPONS } from '../constants';
14 import type { Plan, PlansMap, Pricing, Subscription, SubscriptionCheckResponse } from '../interfaces';
15 import { isDomainAddon, isIpAddon, isMemberAddon, isScribeAddon } from './addons';
16 import { getPlanFromPlanIDs } from './planIDs';
17 import {
18     INCLUDED_IP_PRICING,
19     customCycles,
20     getAddonMultiplier,
21     getMembersFromPlanIDs,
22     getPricePerCycle,
23     getPricingPerMember,
24 } from './subscription';
26 export const getDiscountText = () => {
27     return c('Info')
28         .t`Price includes all applicable cycle-based discounts and non-expired coupons saved to your account.`;
31 export const getUserTitle = (users: number) => {
32     return c('Checkout row').ngettext(msgid`${users} user`, `${users} users`, users);
35 const getAddonQuantity = (addon: Plan, quantity: number) => {
36     let maxKey: MaxKeys | undefined;
37     if (isDomainAddon(addon.Name)) {
38         maxKey = 'MaxDomains';
39     } else if (isMemberAddon(addon.Name)) {
40         maxKey = 'MaxMembers';
41     } else if (isIpAddon(addon.Name)) {
42         maxKey = 'MaxIPs';
43     } else if (isScribeAddon(addon.Name)) {
44         maxKey = 'MaxAI';
45     }
47     /**
48      * Workaround specifically for MaxIPs property. There is an upcoming mirgation in payments API v5
49      * That will structure all these Max* properties in a different way.
50      * For now, we need to handle MaxIPs separately.
51      * See {@link MaxKeys} and {@link Plan}. Note that all properties from MaxKeys must be present in Plan
52      * with the exception of MaxIPs.
53      */
54     const addonMultiplier = maxKey ? getAddonMultiplier(maxKey, addon) : 0;
56     return quantity * addonMultiplier;
59 export const getAddonTitle = (addonName: ADDON_NAMES, quantity: number, planIDs: PlanIDs) => {
60     if (isDomainAddon(addonName)) {
61         const domains = quantity;
62         return c('Addon').ngettext(
63             msgid`${domains} additional custom domain`,
64             `${domains} additional custom domains`,
65             domains
66         );
67     }
68     if (isMemberAddon(addonName)) {
69         const users = quantity;
70         return c('Addon').ngettext(msgid`${users} user`, `${users} users`, users);
71     }
72     if (isIpAddon(addonName)) {
73         const ips = quantity;
74         return c('Addon').ngettext(msgid`${ips} server`, `${ips} servers`, ips);
75     }
77     if (isScribeAddon(addonName)) {
78         const isB2C = planIDs[PLANS.MAIL] || planIDs[PLANS.BUNDLE];
79         if (isB2C) {
80             return c('Info').t`Writing assistant`;
81         }
82         const seats = quantity;
83         // translator: sentence is "1 writing assistant seat" or "2 writing assistant seats"
84         return c('Addon').ngettext(msgid`${seats} writing assistant seat`, `${seats} writing assistant seats`, seats);
85     }
86     return '';
89 export interface AddonDescription {
90     name: ADDON_NAMES;
91     title: string;
92     quantity: number;
93     pricing: Pricing;
96 export type SubscriptionCheckoutData = ReturnType<typeof getCheckout>;
98 export type RequiredCheckResponse = Pick<
99     SubscriptionCheckResponse,
100     | 'Amount'
101     | 'AmountDue'
102     | 'Cycle'
103     | 'CouponDiscount'
104     | 'Proration'
105     | 'Credit'
106     | 'Coupon'
107     | 'Gift'
108     | 'Taxes'
109     | 'TaxInclusive'
110     | 'optimistic'
111     | 'Currency'
114 export const getUsersAndAddons = (planIDs: PlanIDs, plansMap: PlansMap) => {
115     const plan = getPlanFromPlanIDs(plansMap, planIDs);
116     const usersPricing = plan ? getPricingPerMember(plan) : null;
118     const users = getMembersFromPlanIDs(planIDs, plansMap);
119     const viewUsers = getMembersFromPlanIDs(planIDs, plansMap, false);
121     const addonsMap = Object.entries(planIDs).reduce<{
122         [addonName: string]: AddonDescription;
123     }>((acc, [planName, quantity]) => {
124         const planOrAddon = plansMap[planName as keyof typeof plansMap];
125         if (planOrAddon?.Type !== PLAN_TYPES.ADDON || isMemberAddon(planOrAddon.Name)) {
126             return acc;
127         }
129         const name = planOrAddon.Name as ADDON_NAMES;
130         const title = getAddonTitle(name, quantity, planIDs);
131         acc[name] = {
132             name,
133             title,
134             quantity: getAddonQuantity(planOrAddon, quantity),
135             pricing: planOrAddon.Pricing,
136         };
138         return acc;
139     }, {});
141     // VPN Business plan includes 1 IP by default. Each addons adds +1 IP.
142     // So if users has business plan but doesn't have IP addons, then they still must have 1 IP for price
143     // calculation purposes.
144     if (plan?.Name === PLANS.VPN_BUSINESS) {
145         const { IP_VPN_BUSINESS: IP } = ADDON_NAMES;
146         const addon = addonsMap[IP];
148         if (addon) {
149             addon.quantity += 1;
150         } else {
151             addonsMap[IP] = {
152                 name: IP,
153                 quantity: 1,
154                 pricing: plansMap[IP]?.Pricing ?? INCLUDED_IP_PRICING,
155                 title: '',
156             };
157         }
159         addonsMap[IP].title = getAddonTitle(IP, addonsMap[IP].quantity, planIDs);
160     }
162     const addons: AddonDescription[] = Object.values(addonsMap).sort((a, b) => a.name.localeCompare(b.name));
164     const planName = (plan?.Name as PLANS) ?? null;
166     const planTitle = planName === PLANS.PASS_LIFETIME ? PLAN_NAMES[PLANS.PASS_LIFETIME] : (plan?.Title ?? '');
168     return {
169         planName,
170         planTitle,
171         users,
172         viewUsers,
173         usersPricing,
174         addons,
175     };
178 export const getCheckout = ({
179     planIDs,
180     plansMap,
181     checkResult,
182 }: {
183     planIDs: PlanIDs;
184     plansMap: PlansMap;
185     checkResult: RequiredCheckResponse;
186 }) => {
187     const usersAndAddons = getUsersAndAddons(planIDs, plansMap);
189     const amount = checkResult.Amount || 0;
190     const cycle = checkResult.Cycle || CYCLE.MONTHLY;
191     const couponDiscount = Math.abs(checkResult.CouponDiscount || 0);
192     const coupon = checkResult.Coupon?.Code;
193     const isVpnPassPromotion = !!planIDs[PLANS.VPN_PASS_BUNDLE] && VPN_PASS_PROMOTION_COUPONS.includes(coupon as any);
195     const withDiscountPerCycle = amount - couponDiscount;
197     const withoutDiscountPerMonth = Object.entries(planIDs).reduce((acc, [planName, quantity]) => {
198         const plan = plansMap[planName as keyof typeof plansMap];
200         const defaultMonthly = isVpnPassPromotion ? 999 : (plan?.DefaultPricing?.[CYCLE.MONTHLY] ?? 0);
201         const monthly = isVpnPassPromotion ? 999 : (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
203         // Offers might affect Pricing both ways, increase and decrease.
204         // So if the Pricing increases, then we don't want to use the lower DefaultPricing as basis
205         // for discount calculations
206         const price = Math.max(monthly, defaultMonthly);
208         return acc + price * quantity;
209     }, 0);
211     const withoutDiscountPerCycle = withoutDiscountPerMonth * cycle;
212     const withoutDiscountPerNormalCycle = withoutDiscountPerMonth * cycle;
213     const discountPerCycle = Math.min(withoutDiscountPerCycle - withDiscountPerCycle, withoutDiscountPerCycle);
214     const discountPerNormalCycle = Math.min(
215         withoutDiscountPerNormalCycle - withDiscountPerCycle,
216         withoutDiscountPerNormalCycle
217     );
218     const discountPercent =
219         withoutDiscountPerNormalCycle > 0
220             ? Math.round(100 * (discountPerNormalCycle / withoutDiscountPerNormalCycle))
221             : 0;
223     const addonsPerMonth = usersAndAddons.addons.reduce((acc, { quantity, pricing }) => {
224         return acc + ((pricing[cycle] || 0) * quantity) / cycle;
225     }, 0);
227     const membersPerCycle = usersAndAddons.usersPricing?.[cycle] ?? null;
228     const membersPerMonth =
229         membersPerCycle !== null ? (membersPerCycle / cycle) * usersAndAddons.users : amount / cycle - addonsPerMonth;
231     return {
232         couponDiscount: checkResult.CouponDiscount,
233         planIDs,
234         planName: usersAndAddons.planName,
235         planTitle: usersAndAddons.planTitle,
236         addons: usersAndAddons.addons,
237         usersTitle: getUserTitle(usersAndAddons.viewUsers || 1), // VPN and free plan has no users
238         withoutDiscountPerMonth,
239         withoutDiscountPerCycle: amount,
240         withDiscountPerCycle,
241         withDiscountPerMonth: withDiscountPerCycle / cycle,
242         membersPerMonth,
243         discountPerCycle,
244         discountPercent,
245         currency: checkResult.Currency,
246     };
249 export type Included =
250     | {
251           type: 'text';
252           text: string;
253       }
254     | {
255           type: 'value';
256           text: string;
257           value: string | number;
258       };
260 export const getPremiumPasswordManagerText = () => {
261     return c('bf2023: Deal details').t`Premium Password Manager`;
264 export const getOptimisticCheckResult = ({
265     planIDs,
266     plansMap,
267     cycle,
268     currency,
269 }: {
270     cycle: CYCLE;
271     planIDs: PlanIDs | undefined;
272     plansMap: PlansMap;
273     currency: Currency;
274 }): RequiredCheckResponse => {
275     const { amount } = Object.entries(planIDs || {}).reduce(
276         (acc, [planName, quantity]) => {
277             const plan = plansMap?.[planName as keyof typeof plansMap];
278             const price = getPricePerCycle(plan, cycle);
279             if (!plan || !price) {
280                 return acc;
281             }
282             acc.amount += quantity * price;
283             return acc;
284         },
285         { amount: 0 }
286     );
288     return {
289         Amount: amount,
290         AmountDue: amount,
291         CouponDiscount: 0,
292         Cycle: cycle,
293         Proration: 0,
294         Credit: 0,
295         Coupon: null,
296         Gift: 0,
297         optimistic: true,
298         Currency: currency,
299     };
302 export const getCheckResultFromSubscription = (
303     subscription: Subscription | undefined | null
304 ): RequiredCheckResponse => {
305     const Amount = subscription?.Amount || 0;
306     const Discount = subscription?.Discount || 0;
307     const Cycle = subscription?.Cycle || DEFAULT_CYCLE;
308     const Currency = subscription?.Currency || 'USD';
310     // In subscription, Amount includes discount, which is different from the check call.
311     // Here we add them together to be like the check call.
312     const amount = Amount + Math.abs(Discount);
314     return {
315         Amount: amount,
316         AmountDue: amount,
317         Cycle,
318         CouponDiscount: Discount,
319         Proration: 0,
320         Credit: 0,
321         Coupon: null,
322         Gift: 0,
323         Currency,
324     };
327 export const getIsCustomCycle = (cycle: CYCLE) => {
328     return customCycles.includes(cycle);