Use same lock values as mobile clients
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / checkout.ts
blob36e8f5c526df9fa22e1a8504a7ce67f64b21a32f
1 import { c, msgid } from 'ttag';
3 import { ADDON_NAMES, CYCLE, DEFAULT_CYCLE, PLANS, PLAN_TYPES, VPN_PASS_PROMOTION_COUPONS } from '../constants';
4 import type { MaxKeys, Plan, PlanIDs, PlansMap, Pricing, Subscription, SubscriptionCheckResponse } from '../interfaces';
5 import { getMaxValue } from '../interfaces';
6 import { isDomainAddon, isIpAddon, isMemberAddon, isScribeAddon } from './addons';
7 import { getPlanFromCheckout } from './planIDs';
8 import {
9     INCLUDED_IP_PRICING,
10     customCycles,
11     getMembersFromPlanIDs,
12     getPricePerCycle,
13     getPricingPerMember,
14 } from './subscription';
16 export const getDiscountText = () => {
17     return c('Info')
18         .t`Price includes all applicable cycle-based discounts and non-expired coupons saved to your account.`;
21 export const getUserTitle = (users: number) => {
22     return c('Checkout row').ngettext(msgid`${users} user`, `${users} users`, users);
25 const getAddonQuantity = (addon: Plan, quantity: number) => {
26     let maxKey: MaxKeys | undefined;
27     if (isDomainAddon(addon.Name)) {
28         maxKey = 'MaxDomains';
29     } else if (isMemberAddon(addon.Name)) {
30         maxKey = 'MaxMembers';
31     } else if (isIpAddon(addon.Name)) {
32         maxKey = 'MaxIPs';
33     } else if (isScribeAddon(addon.Name)) {
34         maxKey = 'MaxAI';
35     }
37     const multiplier = maxKey ? getMaxValue(addon, maxKey) : 0;
39     return quantity * multiplier;
42 export const getAddonTitle = (addonName: ADDON_NAMES, quantity: number, planIDs: PlanIDs) => {
43     if (isDomainAddon(addonName)) {
44         const domains = quantity;
45         return c('Addon').ngettext(
46             msgid`${domains} additional custom domain`,
47             `${domains} additional custom domains`,
48             domains
49         );
50     }
51     if (isMemberAddon(addonName)) {
52         const users = quantity;
53         return c('Addon').ngettext(msgid`${users} user`, `${users} users`, users);
54     }
55     if (isIpAddon(addonName)) {
56         const ips = quantity;
57         return c('Addon').ngettext(msgid`${ips} server`, `${ips} servers`, ips);
58     }
60     if (isScribeAddon(addonName)) {
61         const isB2C = planIDs[PLANS.MAIL] || planIDs[PLANS.BUNDLE];
62         if (isB2C) {
63             return c('Info').t`Writing assistant`;
64         }
65         const seats = quantity;
66         // translator: sentence is "1 writing assistant seat" or "2 writing assistant seats"
67         return c('Addon').ngettext(msgid`${seats} writing assistant seat`, `${seats} writing assistant seats`, seats);
68     }
69     return '';
72 export interface AddonDescription {
73     name: ADDON_NAMES;
74     title: string;
75     quantity: number;
76     pricing: Pricing;
79 export type SubscriptionCheckoutData = ReturnType<typeof getCheckout>;
81 export type RequiredCheckResponse = Pick<
82     SubscriptionCheckResponse,
83     | 'Amount'
84     | 'AmountDue'
85     | 'Cycle'
86     | 'CouponDiscount'
87     | 'Proration'
88     | 'Credit'
89     | 'Coupon'
90     | 'Gift'
91     | 'Taxes'
92     | 'TaxInclusive'
93     | 'optimistic'
96 export const getUsersAndAddons = (planIDs: PlanIDs, plansMap: PlansMap) => {
97     const plan = getPlanFromCheckout(planIDs, plansMap);
98     const usersPricing = plan ? getPricingPerMember(plan) : null;
100     const users = getMembersFromPlanIDs(planIDs, plansMap);
101     const viewUsers = getMembersFromPlanIDs(planIDs, plansMap, false);
103     const addonsMap = Object.entries(planIDs).reduce<{
104         [addonName: string]: AddonDescription;
105     }>((acc, [planName, quantity]) => {
106         const planOrAddon = plansMap[planName as keyof typeof plansMap];
107         if (planOrAddon?.Type !== PLAN_TYPES.ADDON || isMemberAddon(planOrAddon.Name)) {
108             return acc;
109         }
111         const name = planOrAddon.Name as ADDON_NAMES;
112         const title = getAddonTitle(name, quantity, planIDs);
113         acc[name] = {
114             name,
115             title,
116             quantity: getAddonQuantity(planOrAddon, quantity),
117             pricing: planOrAddon.Pricing,
118         };
120         return acc;
121     }, {});
123     // VPN Business plan includes 1 IP by default. Each addons adds +1 IP.
124     // So if users has business plan but doesn't have IP addons, then they still must have 1 IP for price
125     // calculation purposes.
126     if (plan?.Name === PLANS.VPN_BUSINESS) {
127         const { IP_VPN_BUSINESS: IP } = ADDON_NAMES;
128         const addon = addonsMap[IP];
130         if (addon) {
131             addon.quantity += 1;
132         } else {
133             addonsMap[IP] = {
134                 name: IP,
135                 quantity: 1,
136                 pricing: plansMap[IP]?.Pricing ?? INCLUDED_IP_PRICING,
137                 title: '',
138             };
139         }
141         addonsMap[IP].title = getAddonTitle(IP, addonsMap[IP].quantity, planIDs);
142     }
144     const addons: AddonDescription[] = Object.values(addonsMap).sort((a, b) => a.name.localeCompare(b.name));
146     const planName = (plan?.Name as PLANS) ?? null;
147     const planTitle = plan?.Title ?? '';
149     return {
150         planName,
151         planTitle,
152         users,
153         viewUsers,
154         usersPricing,
155         addons,
156     };
159 export const getCheckout = ({
160     planIDs,
161     plansMap,
162     checkResult,
163 }: {
164     planIDs: PlanIDs;
165     plansMap: PlansMap;
166     checkResult?: RequiredCheckResponse;
167 }) => {
168     const usersAndAddons = getUsersAndAddons(planIDs, plansMap);
170     const amount = checkResult?.Amount || 0;
171     const cycle = checkResult?.Cycle || CYCLE.MONTHLY;
172     const couponDiscount = Math.abs(checkResult?.CouponDiscount || 0);
173     const coupon = checkResult?.Coupon?.Code;
174     const isVpnPassPromotion = !!planIDs[PLANS.VPN_PASS_BUNDLE] && VPN_PASS_PROMOTION_COUPONS.includes(coupon as any);
176     const withDiscountPerCycle = amount - couponDiscount;
178     const withoutDiscountPerMonth = Object.entries(planIDs).reduce((acc, [planName, quantity]) => {
179         const plan = plansMap[planName as keyof typeof plansMap];
181         const defaultMonthly = isVpnPassPromotion ? 999 : (plan?.DefaultPricing?.[CYCLE.MONTHLY] ?? 0);
182         const monthly = isVpnPassPromotion ? 999 : (getPricePerCycle(plan, CYCLE.MONTHLY) ?? 0);
184         // Offers might affect Pricing both ways, increase and decrease.
185         // So if the Pricing increases, then we don't want to use the lower DefaultPricing as basis
186         // for discount calculations
187         const price = Math.max(monthly, defaultMonthly);
189         return acc + price * quantity;
190     }, 0);
192     const withoutDiscountPerCycle = withoutDiscountPerMonth * cycle;
193     const withoutDiscountPerNormalCycle = withoutDiscountPerMonth * cycle;
194     const discountPerCycle = Math.min(withoutDiscountPerCycle - withDiscountPerCycle, withoutDiscountPerCycle);
195     const discountPerNormalCycle = Math.min(
196         withoutDiscountPerNormalCycle - withDiscountPerCycle,
197         withoutDiscountPerNormalCycle
198     );
199     const discountPercent =
200         withoutDiscountPerNormalCycle > 0
201             ? Math.round(100 * (discountPerNormalCycle / withoutDiscountPerNormalCycle))
202             : 0;
204     const addonsPerMonth = usersAndAddons.addons.reduce((acc, { quantity, pricing }) => {
205         return acc + ((pricing[cycle] || 0) * quantity) / cycle;
206     }, 0);
208     const membersPerCycle = usersAndAddons.usersPricing?.[cycle] ?? null;
209     const membersPerMonth =
210         membersPerCycle !== null ? (membersPerCycle / cycle) * usersAndAddons.users : amount / cycle - addonsPerMonth;
212     return {
213         couponDiscount: checkResult?.CouponDiscount,
214         planIDs,
215         planName: usersAndAddons.planName,
216         planTitle: usersAndAddons.planTitle,
217         addons: usersAndAddons.addons,
218         usersTitle: getUserTitle(usersAndAddons.viewUsers || 1), // VPN and free plan has no users
219         withoutDiscountPerMonth,
220         withoutDiscountPerCycle: amount,
221         withDiscountPerCycle,
222         withDiscountPerMonth: withDiscountPerCycle / cycle,
223         membersPerMonth,
224         discountPerCycle,
225         discountPercent,
226     };
229 export type Included =
230     | {
231           type: 'text';
232           text: string;
233       }
234     | {
235           type: 'value';
236           text: string;
237           value: string | number;
238       };
240 export const getPremiumPasswordManagerText = () => {
241     return c('bf2023: Deal details').t`Premium Password Manager`;
244 export const getOptimisticCheckResult = ({
245     planIDs,
246     plansMap,
247     cycle,
248 }: {
249     cycle: CYCLE;
250     planIDs: PlanIDs | undefined;
251     plansMap: PlansMap;
252 }): RequiredCheckResponse => {
253     const { amount } = Object.entries(planIDs || {}).reduce(
254         (acc, [planName, quantity]) => {
255             const plan = plansMap?.[planName as keyof typeof plansMap];
256             const price = getPricePerCycle(plan, cycle);
257             if (!plan || !price) {
258                 return acc;
259             }
260             acc.amount += quantity * price;
261             return acc;
262         },
263         { amount: 0 }
264     );
266     return {
267         Amount: amount,
268         AmountDue: amount,
269         CouponDiscount: 0,
270         Cycle: cycle,
271         Proration: 0,
272         Credit: 0,
273         Coupon: null,
274         Gift: 0,
275         optimistic: true,
276     };
279 export const getCheckResultFromSubscription = (
280     subscription: Subscription | undefined | null
281 ): RequiredCheckResponse => {
282     const Amount = subscription?.Amount || 0;
283     const Discount = subscription?.Discount || 0;
284     const Cycle = subscription?.Cycle || DEFAULT_CYCLE;
286     // In subscription, Amount includes discount, which is different from the check call.
287     // Here we add them together to be like the check call.
288     const amount = Amount + Math.abs(Discount);
290     return {
291         Amount: amount,
292         AmountDue: amount,
293         Cycle,
294         CouponDiscount: Discount,
295         Proration: 0,
296         Credit: 0,
297         Coupon: null,
298         Gift: 0,
299     };
302 export const getIsCustomCycle = (cycle: CYCLE) => {
303     return customCycles.includes(cycle);