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';
11 getMembersFromPlanIDs,
14 } from './subscription';
16 export const getDiscountText = () => {
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)) {
33 } else if (isScribeAddon(addon.Name)) {
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`,
51 if (isMemberAddon(addonName)) {
52 const users = quantity;
53 return c('Addon').ngettext(msgid`${users} user`, `${users} users`, users);
55 if (isIpAddon(addonName)) {
57 return c('Addon').ngettext(msgid`${ips} server`, `${ips} servers`, ips);
60 if (isScribeAddon(addonName)) {
61 const isB2C = planIDs[PLANS.MAIL] || planIDs[PLANS.BUNDLE];
63 return c('Info').t`Writing assistant`;
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);
72 export interface AddonDescription {
79 export type SubscriptionCheckoutData = ReturnType<typeof getCheckout>;
81 export type RequiredCheckResponse = Pick<
82 SubscriptionCheckResponse,
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)) {
111 const name = planOrAddon.Name as ADDON_NAMES;
112 const title = getAddonTitle(name, quantity, planIDs);
116 quantity: getAddonQuantity(planOrAddon, quantity),
117 pricing: planOrAddon.Pricing,
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];
136 pricing: plansMap[IP]?.Pricing ?? INCLUDED_IP_PRICING,
141 addonsMap[IP].title = getAddonTitle(IP, addonsMap[IP].quantity, planIDs);
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 ?? '';
159 export const getCheckout = ({
166 checkResult?: RequiredCheckResponse;
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;
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
199 const discountPercent =
200 withoutDiscountPerNormalCycle > 0
201 ? Math.round(100 * (discountPerNormalCycle / withoutDiscountPerNormalCycle))
204 const addonsPerMonth = usersAndAddons.addons.reduce((acc, { quantity, pricing }) => {
205 return acc + ((pricing[cycle] || 0) * quantity) / cycle;
208 const membersPerCycle = usersAndAddons.usersPricing?.[cycle] ?? null;
209 const membersPerMonth =
210 membersPerCycle !== null ? (membersPerCycle / cycle) * usersAndAddons.users : amount / cycle - addonsPerMonth;
213 couponDiscount: checkResult?.CouponDiscount,
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,
229 export type Included =
237 value: string | number;
240 export const getPremiumPasswordManagerText = () => {
241 return c('bf2023: Deal details').t`Premium Password Manager`;
244 export const getOptimisticCheckResult = ({
250 planIDs: PlanIDs | undefined;
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) {
260 acc.amount += quantity * price;
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);
294 CouponDiscount: Discount,
302 export const getIsCustomCycle = (cycle: CYCLE) => {
303 return customCycles.includes(cycle);