1 import { c, msgid } from 'ttag';
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';
21 getMembersFromPlanIDs,
24 } from './subscription';
26 export const getDiscountText = () => {
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)) {
43 } else if (isScribeAddon(addon.Name)) {
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.
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`,
68 if (isMemberAddon(addonName)) {
69 const users = quantity;
70 return c('Addon').ngettext(msgid`${users} user`, `${users} users`, users);
72 if (isIpAddon(addonName)) {
74 return c('Addon').ngettext(msgid`${ips} server`, `${ips} servers`, ips);
77 if (isScribeAddon(addonName)) {
78 const isB2C = planIDs[PLANS.MAIL] || planIDs[PLANS.BUNDLE];
80 return c('Info').t`Writing assistant`;
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);
89 export interface AddonDescription {
96 export type SubscriptionCheckoutData = ReturnType<typeof getCheckout>;
98 export type RequiredCheckResponse = Pick<
99 SubscriptionCheckResponse,
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)) {
129 const name = planOrAddon.Name as ADDON_NAMES;
130 const title = getAddonTitle(name, quantity, planIDs);
134 quantity: getAddonQuantity(planOrAddon, quantity),
135 pricing: planOrAddon.Pricing,
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];
154 pricing: plansMap[IP]?.Pricing ?? INCLUDED_IP_PRICING,
159 addonsMap[IP].title = getAddonTitle(IP, addonsMap[IP].quantity, planIDs);
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 ?? '');
178 export const getCheckout = ({
185 checkResult: RequiredCheckResponse;
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;
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
218 const discountPercent =
219 withoutDiscountPerNormalCycle > 0
220 ? Math.round(100 * (discountPerNormalCycle / withoutDiscountPerNormalCycle))
223 const addonsPerMonth = usersAndAddons.addons.reduce((acc, { quantity, pricing }) => {
224 return acc + ((pricing[cycle] || 0) * quantity) / cycle;
227 const membersPerCycle = usersAndAddons.usersPricing?.[cycle] ?? null;
228 const membersPerMonth =
229 membersPerCycle !== null ? (membersPerCycle / cycle) * usersAndAddons.users : amount / cycle - addonsPerMonth;
232 couponDiscount: checkResult.CouponDiscount,
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,
245 currency: checkResult.Currency,
249 export type Included =
257 value: string | number;
260 export const getPremiumPasswordManagerText = () => {
261 return c('bf2023: Deal details').t`Premium Password Manager`;
264 export const getOptimisticCheckResult = ({
271 planIDs: PlanIDs | undefined;
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) {
282 acc.amount += quantity * price;
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);
318 CouponDiscount: Discount,
327 export const getIsCustomCycle = (cycle: CYCLE) => {
328 return customCycles.includes(cycle);