1 import { addWeeks, fromUnixTime, isBefore } from 'date-fns';
13 onSessionMigrationChargebeeStatus,
14 } from '@proton/payments';
15 import { type FreeSubscription, isFreeSubscription } from '@proton/payments';
16 import type { ProductParam } from '@proton/shared/lib/apps/product';
17 import { getSupportedAddons, isIpAddon, isMemberAddon, isScribeAddon } from '@proton/shared/lib/helpers/addons';
19 import { APPS, type APP_NAMES, COUPON_CODES, CYCLE } from '../constants';
26 SubscriptionCheckResponse,
30 } from '../interfaces';
31 import { Audience, ChargebeeEnabled, External, TaxInclusive } from '../interfaces';
32 import { hasBit } from './bitset';
34 const { PLAN, ADDON } = PLAN_TYPES;
64 MEMBER_SCRIBE_MAILPLUS,
65 MEMBER_SCRIBE_MAIL_BUSINESS,
66 MEMBER_SCRIBE_DRIVEPLUS,
70 MEMBER_SCRIBE_VPN2024,
71 MEMBER_SCRIBE_VPN_PASS_BUNDLE,
72 MEMBER_SCRIBE_MAIL_PRO,
73 MEMBER_SCRIBE_BUNDLE_PRO,
74 MEMBER_SCRIBE_BUNDLE_PRO_2024,
75 MEMBER_SCRIBE_PASS_PRO,
76 MEMBER_SCRIBE_VPN_BIZ,
77 MEMBER_SCRIBE_PASS_BIZ,
78 MEMBER_SCRIBE_VPN_PRO,
83 type MaybeFreeSubscription = Subscription | FreeSubscription | undefined;
85 export const getPlan = (subscription: Subscription | FreeSubscription | undefined, service?: PLAN_SERVICES) => {
86 const result = (subscription?.Plans || []).find(
87 ({ Services, Type }) => Type === PLAN && (service === undefined ? true : hasBit(Services, service))
90 return result as SubscriptionPlan & { Name: PLANS };
95 export const getAddons = (subscription: Subscription | undefined) =>
96 (subscription?.Plans || []).filter(({ Type }) => Type === ADDON);
97 export const hasAddons = (subscription: Subscription | undefined) =>
98 (subscription?.Plans || []).some(({ Type }) => Type === ADDON);
100 export const getPlanName = (subscription: Subscription | FreeSubscription | undefined, service?: PLAN_SERVICES) => {
101 const plan = getPlan(subscription, service);
105 export const getPlanTitle = (subscription: Subscription | undefined) => {
106 const plan = getPlan(subscription);
110 export const hasSomePlan = (subscription: MaybeFreeSubscription, planName: PLANS) => {
111 if (isFreeSubscription(subscription)) {
115 return (subscription?.Plans || []).some(({ Name }) => Name === planName);
118 export const hasSomeAddonOrPlan = (
119 subscription: MaybeFreeSubscription,
120 addonName: ADDON_NAMES | PLANS | (ADDON_NAMES | PLANS)[]
122 if (isFreeSubscription(subscription)) {
126 if (Array.isArray(addonName)) {
127 return (subscription?.Plans || []).some(({ Name }) => addonName.includes(Name as ADDON_NAMES));
130 return (subscription?.Plans || []).some(({ Name }) => Name === addonName);
133 export const hasLifetime = (subscription: Subscription | undefined) => {
134 return subscription?.CouponCode === COUPON_CODES.LIFETIME;
137 export const hasMigrationDiscount = (subscription?: Subscription) => {
138 return subscription?.CouponCode?.startsWith('MIGRATION');
141 export const isManagedExternally = (
142 subscription: Subscription | FreeSubscription | Pick<Subscription, 'External'> | undefined | null
144 if (!subscription || isFreeSubscription(subscription)) {
148 return subscription.External === External.Android || subscription.External === External.iOS;
151 export const hasVisionary = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VISIONARY);
152 export const hasVPN = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN);
153 export const hasVPN2024 = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN2024);
154 export const hasVPNPassBundle = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_PASS_BUNDLE);
155 export const hasMail = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL);
156 export const hasMailPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL_PRO);
157 export const hasMailBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL_BUSINESS);
158 export const hasDrive = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE);
159 export const hasDrivePro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE_PRO);
160 export const hasDriveBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE_BUSINESS);
161 export const hasPass = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS);
162 export const hasWallet = (subscription: MaybeFreeSubscription) => hasSomeAddonOrPlan(subscription, WALLET);
163 export const hasEnterprise = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, ENTERPRISE);
164 export const hasBundle = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE);
165 export const hasBundlePro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE_PRO);
166 export const hasBundlePro2024 = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE_PRO_2024);
167 export const hasFamily = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, FAMILY);
168 export const hasDuo = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DUO);
169 export const hasVpnPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_PRO);
170 export const hasVpnBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_BUSINESS);
171 export const hasPassPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_PRO);
172 export const hasPassFamily = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_FAMILY);
173 export const hasPassBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_BUSINESS);
174 export const hasFree = (subscription: MaybeFreeSubscription) => (subscription?.Plans || []).length === 0;
176 export const hasAnyBundlePro = (subscription: MaybeFreeSubscription) =>
177 hasBundlePro(subscription) || hasBundlePro2024(subscription);
179 const hasAIAssistantCondition = [
180 MEMBER_SCRIBE_MAILPLUS,
181 MEMBER_SCRIBE_MAIL_BUSINESS,
182 MEMBER_SCRIBE_DRIVEPLUS,
183 MEMBER_SCRIBE_BUNDLE,
186 MEMBER_SCRIBE_VPN2024,
187 MEMBER_SCRIBE_VPN_PASS_BUNDLE,
188 MEMBER_SCRIBE_MAIL_PRO,
189 MEMBER_SCRIBE_BUNDLE_PRO,
190 MEMBER_SCRIBE_BUNDLE_PRO_2024,
191 MEMBER_SCRIBE_PASS_PRO,
192 MEMBER_SCRIBE_VPN_BIZ,
193 MEMBER_SCRIBE_PASS_BIZ,
194 MEMBER_SCRIBE_VPN_PRO,
195 MEMBER_SCRIBE_FAMILY,
198 export const hasAIAssistant = (subscription: MaybeFreeSubscription) =>
199 hasSomeAddonOrPlan(subscription, hasAIAssistantCondition);
201 export const PLANS_WITH_AI_INCLUDED = [VISIONARY, DUO, FAMILY];
202 export const hasPlanWithAIAssistantIncluded = (subscription: MaybeFreeSubscription) =>
203 hasSomeAddonOrPlan(subscription, PLANS_WITH_AI_INCLUDED);
205 export const hasAllProductsB2CPlan = (subscription: MaybeFreeSubscription) =>
206 hasDuo(subscription) || hasFamily(subscription) || hasBundle(subscription) || hasVisionary(subscription);
208 export const getUpgradedPlan = (subscription: Subscription | undefined, app: ProductParam) => {
209 if (hasFree(subscription)) {
211 case APPS.PROTONPASS:
213 case APPS.PROTONDRIVE:
215 case APPS.PROTONVPN_SETTINGS:
217 case APPS.PROTONWALLET:
220 case APPS.PROTONMAIL:
224 if (hasBundle(subscription) || hasBundlePro(subscription) || hasBundlePro2024(subscription)) {
225 return PLANS.BUNDLE_PRO_2024;
230 const b2bPlans: Set<PLANS | ADDON_NAMES> = new Set([
243 export const getIsB2BAudienceFromPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
248 return b2bPlans.has(planName);
251 const canCheckItemPaidChecklistCondition: Set<PLANS | ADDON_NAMES> = new Set([MAIL, DRIVE, FAMILY, DUO, BUNDLE]);
252 export const canCheckItemPaidChecklist = (subscription: Subscription | undefined) => {
253 return subscription?.Plans?.some(({ Name }) => canCheckItemPaidChecklistCondition.has(Name));
256 const canCheckItemGetStartedCondition: Set<PLANS | ADDON_NAMES> = new Set([
263 export const canCheckItemGetStarted = (subscription: Subscription | undefined) => {
264 return subscription?.Plans?.some(({ Name }) => canCheckItemGetStartedCondition.has(Name));
267 const getIsVpnB2BPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN_PRO, VPN_BUSINESS]);
268 export const getIsVpnB2BPlan = (planName: PLANS | ADDON_NAMES) => getIsVpnB2BPlanCondition.has(planName);
270 const getIsVpnPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN, VPN2024, VPN_PASS_BUNDLE, VPN_PRO, VPN_BUSINESS]);
271 export const getIsVpnPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
275 return getIsVpnPlanCondition.has(planName);
278 const getIsConsumerVpnPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN, VPN2024, VPN_PASS_BUNDLE]);
279 export const getIsConsumerVpnPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
283 return getIsConsumerVpnPlanCondition.has(planName);
286 const getIsPassB2BPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([PASS_PRO, PASS_BUSINESS]);
287 export const getIsPassB2BPlan = (planName?: PLANS | ADDON_NAMES) => {
291 return getIsPassB2BPlanCondition.has(planName);
294 const getIsPassPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
301 export const getIsPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
305 return getIsPassPlanCondition.has(planName);
308 const consumerPassPlanSet: Set<PLANS | ADDON_NAMES> = new Set([PASS, PASS_FAMILY, VPN_PASS_BUNDLE]);
309 export const getIsConsumerPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
313 return consumerPassPlanSet.has(planName);
316 const getCanAccessDuoPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
328 PLANS.BUNDLE_PRO_2024,
330 export const getCanSubscriptionAccessDuoPlan = (subscription?: MaybeFreeSubscription) => {
331 return hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessDuoPlanCondition.has(Name));
334 const getCanAccessPassFamilyPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([PLANS.PASS]);
335 export const getCanSubscriptionAccessPassFamilyPlan = (subscription?: MaybeFreeSubscription) => {
337 hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessPassFamilyPlanCondition.has(Name))
341 const getIsSentinelPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
355 export const getIsSentinelPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
359 return getIsSentinelPlanCondition.has(planName);
362 const lifetimePlans: Set<PLANS | ADDON_NAMES> = new Set([PASS_LIFETIME]);
363 export const isLifetimePlan = (planName: PLANS | ADDON_NAMES | undefined) => {
368 return lifetimePlans.has(planName);
371 export const getIsB2BAudienceFromSubscription = (subscription: Subscription | undefined) => {
372 return !!subscription?.Plans?.some(({ Name }) => getIsB2BAudienceFromPlan(Name));
375 export const getHasVpnB2BPlan = (subscription: MaybeFreeSubscription) => {
376 return hasVpnPro(subscription) || hasVpnBusiness(subscription);
379 export const appSupportsSSO = (appName?: APP_NAMES) => {
380 return appName && [APPS.PROTONVPN_SETTINGS, APPS.PROTONPASS].some((ssoPlanName) => ssoPlanName === appName);
383 export const planSupportsSSO = (planName?: PLANS) => {
384 return planName && [PLANS.VPN_BUSINESS, PLANS.PASS_BUSINESS].some((ssoPlanName) => ssoPlanName === planName);
387 export const upsellPlanSSO = (planName?: PLANS) => {
388 return planName && [PLANS.VPN_PRO, PLANS.PASS_PRO].some((ssoPlanName) => ssoPlanName === planName);
391 export const getHasSomeVpnPlan = (subscription: MaybeFreeSubscription) => {
393 hasVPN(subscription) ||
394 hasVPN2024(subscription) ||
395 hasVPNPassBundle(subscription) ||
396 hasVpnPro(subscription) ||
397 hasVpnBusiness(subscription)
401 export const getHasConsumerVpnPlan = (subscription: MaybeFreeSubscription) => {
402 return hasVPN(subscription) || hasVPN2024(subscription) || hasVPNPassBundle(subscription);
405 export const getHasPassB2BPlan = (subscription: MaybeFreeSubscription) => {
406 return hasPassPro(subscription) || hasPassBusiness(subscription);
409 export const getHasDriveB2BPlan = (subscription: MaybeFreeSubscription) => {
410 return hasDrivePro(subscription) || hasDriveBusiness(subscription);
413 const externalMemberB2BPlans: Set<PLANS | ADDON_NAMES> = new Set([
421 export const getHasExternalMemberCapableB2BPlan = (subscription: MaybeFreeSubscription) => {
422 return subscription?.Plans?.some((plan) => externalMemberB2BPlans.has(plan.Name)) || false;
425 export const getHasMailB2BPlan = (subscription: MaybeFreeSubscription) => {
426 return hasMailPro(subscription) || hasMailBusiness(subscription);
429 export const getHasInboxB2BPlan = (subscription: MaybeFreeSubscription) => {
430 return hasAnyBundlePro(subscription) || getHasMailB2BPlan(subscription);
433 export const getPrimaryPlan = (subscription: Subscription | undefined) => {
438 return getPlan(subscription);
441 export const getBaseAmount = (
442 name: PLANS | ADDON_NAMES,
444 subscription: Subscription | undefined,
445 cycle = CYCLE.MONTHLY
447 const base = plansMap[name];
451 return (subscription?.Plans || [])
452 .filter(({ Name }) => Name === name)
454 const pricePerCycle = base.Pricing[cycle] || 0;
455 return acc + pricePerCycle;
459 export const getPlanIDs = (subscription: MaybeFreeSubscription | null): PlanIDs => {
460 return (subscription?.Plans || []).reduce<PlanIDs>((acc, { Name, Quantity }) => {
461 acc[Name] = (acc[Name] || 0) + Quantity;
466 export const isTrial = (subscription: Subscription | FreeSubscription | undefined, plan?: PLANS): boolean => {
467 if (isFreeSubscription(subscription)) {
472 subscription?.CouponCode === COUPON_CODES.REFERRAL ||
473 subscription?.CouponCode === COUPON_CODES.MEMBER_DOWNGRADE_TRIAL;
474 const isTrialV5 = !!subscription?.IsTrial;
475 const trial = isTrialV4 || isTrialV5;
481 return trial && getPlanName(subscription) === plan;
484 export const isTrialExpired = (subscription: Subscription | undefined) => {
485 const now = new Date();
486 return now > fromUnixTime(subscription?.PeriodEnd || 0);
489 export const willTrialExpire = (subscription: Subscription | undefined) => {
490 const now = new Date();
491 return isBefore(fromUnixTime(subscription?.PeriodEnd || 0), addWeeks(now, 1));
494 export const getHasMemberCapablePlan = (
495 organization: Organization | undefined,
496 subscription: Subscription | undefined
498 const supportedAddons = getSupportedAddons(getPlanIDs(subscription));
499 return (organization?.MaxMembers || 0) > 1 || (Object.keys(supportedAddons) as ADDON_NAMES[]).some(isMemberAddon);
502 const endOfYearDiscountCoupons: Set<string> = new Set([
503 COUPON_CODES.END_OF_YEAR_2023,
504 COUPON_CODES.BLACK_FRIDAY_2023,
505 COUPON_CODES.EOY_2023_1M_INTRO,
507 export const getHas2023OfferCoupon = (coupon: string | undefined | null): boolean => {
511 return endOfYearDiscountCoupons.has(coupon);
513 const blackFriday2024Discounts: Set<string> = new Set([
514 COUPON_CODES.BLACK_FRIDAY_2024,
515 COUPON_CODES.BLACK_FRIDAY_2024_MONTH,
516 COUPON_CODES.BLACK_FRIDAY_2024_PCMAG,
517 COUPON_CODES.BLACK_FRIDAY_2024_HB,
518 COUPON_CODES.BLACK_FRIDAY_2024_VPNLIGHTNING,
519 COUPON_CODES.BLACK_FRIDAY_2024_PASS_LIFE,
521 export const getHas2024OfferCoupon = (coupon: string | undefined | null): boolean => {
525 return blackFriday2024Discounts.has(coupon?.toUpperCase());
528 export const allCycles = Object.freeze(
530 .filter((cycle): cycle is CYCLE => typeof cycle === 'number')
531 .sort((a, b) => a - b)
533 export const regularCycles = Object.freeze([CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS]);
534 export const customCycles = Object.freeze(allCycles.filter((cycle) => !regularCycles.includes(cycle)));
536 export const getValidCycle = (cycle: number): CYCLE | undefined => {
537 return allCycles.includes(cycle) ? cycle : undefined;
540 const getValidAudienceCondition = [Audience.B2B, Audience.B2C, Audience.FAMILY];
541 export const getValidAudience = (audience: string | undefined | null): Audience | undefined => {
542 return getValidAudienceCondition.find((realAudience) => realAudience === audience);
545 export const getIsCustomCycle = (subscription?: Subscription) => {
549 return customCycles.includes(subscription.Cycle);
552 export function getNormalCycleFromCustomCycle(cycle: CYCLE): CYCLE;
553 export function getNormalCycleFromCustomCycle(cycle: undefined): undefined;
554 export function getNormalCycleFromCustomCycle(cycle: CYCLE | undefined): CYCLE | undefined;
555 export function getNormalCycleFromCustomCycle(cycle: CYCLE | undefined): CYCLE | undefined {
560 if (regularCycles.includes(cycle)) {
564 // find the closest lower regular cycle
565 for (let i = regularCycles.length - 1; i >= 0; i--) {
566 const regularCycle = regularCycles[i];
568 if (regularCycle < cycle) {
573 // well, that should be unreachable, but let it be just in case
574 return CYCLE.MONTHLY;
577 export function getLongerCycle(cycle: CYCLE): CYCLE;
578 export function getLongerCycle(cycle: CYCLE | undefined): CYCLE | undefined {
582 if (cycle === CYCLE.MONTHLY) {
585 if (cycle === CYCLE.YEARLY) {
586 return CYCLE.TWO_YEARS;
589 if (cycle === CYCLE.FIFTEEN || cycle === CYCLE.THIRTY) {
590 return CYCLE.TWO_YEARS;
596 export const hasYearly = (subscription?: Subscription) => {
597 return subscription?.Cycle === CYCLE.YEARLY;
600 export const hasMonthly = (subscription?: Subscription) => {
601 return subscription?.Cycle === CYCLE.MONTHLY;
604 export const hasTwoYears = (subscription?: Subscription) => {
605 return subscription?.Cycle === CYCLE.TWO_YEARS;
608 export const hasFifteen = (subscription?: Subscription) => {
609 return subscription?.Cycle === CYCLE.FIFTEEN;
612 export const hasThirty = (subscription?: Subscription) => {
613 return subscription?.Cycle === CYCLE.THIRTY;
616 export interface PricingForCycles {
617 [CYCLE.MONTHLY]: number;
618 [CYCLE.THREE]: number;
619 [CYCLE.YEARLY]: number;
620 [CYCLE.EIGHTEEN]: number;
621 [CYCLE.TWO_YEARS]: number;
622 [CYCLE.FIFTEEN]: number;
623 [CYCLE.THIRTY]: number;
626 export interface AggregatedPricing {
627 all: PricingForCycles;
628 defaultMonthlyPrice: number;
629 defaultMonthlyPriceWithoutAddons: number;
631 * That's pricing that counts only aggregate of cost for members. That's useful for rendering of
632 * "per user per month" pricing.
634 * - If you have a B2C plan with 1 user, then this price will be the same as `all`.
635 * - If you have Mail Plus plan with several users, then this price will be the same as `all`, because each
636 * additional member counts to the price of members.
637 * - If you have Bundle Pro with several users and with the default (minimum) number of custom domains, then
638 * this price will be the same as `all`.
640 * Here things become different:
641 * - If you have Bundle Pro with several users and with more than the default (minimum) number of custom domains,
642 * then this price will be `all - extra custom domains price`.
643 * - For VPN Business the behavior is more complex. It also has two addons: member and IPs/servers. By default it
644 * has 2 members and 1 IP. The price for members should exclude price for the 1 default IP.
646 members: PricingForCycles;
647 membersNumber: number;
648 plans: PricingForCycles;
651 function isMultiUserPersonalPlan(plan: Plan) {
652 // even though Duo, Family and Visionary plans can have up to 6 users in the org,
653 // for the price displaying purposes we count it as 1 member.
654 return plan.Name === PLANS.DUO || plan.Name === PLANS.FAMILY || plan.Name === PLANS.VISIONARY;
657 export function getPlanMembers(plan: Plan, quantity: number, view = true): number {
658 const hasMembers = plan.Type === PLAN_TYPES.PLAN || (plan.Type === PLAN_TYPES.ADDON && isMemberAddon(plan.Name));
660 let membersNumberInPlan = 0;
661 if (isMultiUserPersonalPlan(plan) && view) {
662 membersNumberInPlan = 1;
663 } else if (hasMembers) {
664 membersNumberInPlan = plan.MaxMembers || 1;
667 return membersNumberInPlan * quantity;
670 export function getMembersFromPlanIDs(planIDs: PlanIDs, plansMap: PlansMap, view = true): number {
671 return (Object.entries(planIDs) as [PLANS | ADDON_NAMES, number][]).reduce((acc, [name, quantity]) => {
672 const plan = plansMap[name];
677 return acc + getPlanMembers(plan, quantity, view);
681 export const INCLUDED_IP_PRICING = {
682 [CYCLE.MONTHLY]: 4999,
683 [CYCLE.YEARLY]: 3999 * CYCLE.YEARLY,
684 [CYCLE.TWO_YEARS]: 3599 * CYCLE.TWO_YEARS,
687 function getIpPrice(cycle: CYCLE): number {
688 if (cycle === CYCLE.MONTHLY) {
689 return INCLUDED_IP_PRICING[CYCLE.MONTHLY];
692 if (cycle === CYCLE.YEARLY) {
693 return INCLUDED_IP_PRICING[CYCLE.YEARLY];
696 if (cycle === CYCLE.TWO_YEARS) {
697 return INCLUDED_IP_PRICING[CYCLE.TWO_YEARS];
703 export function getIpPricePerMonth(cycle: CYCLE): number {
704 return getIpPrice(cycle) / cycle;
708 * The purpose of this overridden price is to show a coupon discount in the cycle selector. If that would be supported
709 * this would not be needed.
711 export const getPricePerCycle = (plan: Plan | undefined, cycle: CYCLE) => {
712 return plan?.Pricing?.[cycle];
715 export function getPricePerMember(plan: Plan, cycle: CYCLE): number {
716 const totalPrice = getPricePerCycle(plan, cycle) || 0;
718 if (plan.Name === PLANS.VPN_BUSINESS) {
719 // For VPN business, we exclude IP price from calculation. And we also divide by 2,
720 // because it has 2 members by default too.
721 const IP_PRICE = getIpPrice(cycle);
722 return (totalPrice - IP_PRICE) / (plan.MaxMembers || 1);
725 if (isMultiUserPersonalPlan(plan)) {
729 // Some plans have 0 MaxMembers. That's because they don't have access to mail.
730 // In reality, they still get 1 member.
731 return totalPrice / (plan.MaxMembers || 1);
734 export function getPricingPerMember(plan: Plan): Pricing {
735 return allCycles.reduce((acc, cycle) => {
736 acc[cycle] = getPricePerMember(plan, cycle);
738 // If the plan doesn't have custom cycles, we need to remove it from the resulting Pricing object
739 const isNonDefinedCycle = acc[cycle] === undefined || acc[cycle] === null || acc[cycle] === 0;
740 if (customCycles.includes(cycle) && isNonDefinedCycle) {
748 interface OfferResult {
754 export const getPlanOffer = (plan: Plan) => {
755 const result = [CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS].reduce<OfferResult>(
757 acc.pricing[cycle] = (plan.DefaultPricing?.[cycle] ?? 0) - (getPricePerCycle(plan, cycle) ?? 0);
767 [CYCLE.TWO_YEARS]: 0,
774 const sortedResults = (Object.entries(result.pricing) as unknown as [CYCLE, number][]).sort((a, b) => b[1] - a[1]);
775 result.cycles = sortedResults.map(([cycle]) => cycle);
776 if (sortedResults[0][1] > 0) {
782 const IPS_INCLUDED_IN_PLAN: Partial<Record<PLANS, number>> = {
783 [PLANS.VPN_BUSINESS]: 1,
784 [PLANS.BUNDLE_PRO]: 0,
785 [PLANS.BUNDLE_PRO_2024]: 0,
789 * Currently there is no convenient way to get the number of IPs for a VPN subscription.
790 * There is no dedicated field for that in the API.
791 * That's a hack that counts the number of IP addons.
793 export const getVPNDedicatedIPs = (subscription: Subscription | undefined) => {
794 const planName = getPlanName(subscription, PLAN_SERVICES.VPN);
796 // If you have other VPN plans, they don't have dedicated IPs
801 // Some plans might have included IPs without any indication on the backend.
802 // For example, 1 IP is included in the Business plan
803 const includedIPs = IPS_INCLUDED_IN_PLAN[planName] || 0;
805 return (subscription as Subscription).Plans.reduce(
806 (acc, { Name: addonOrPlanName, Quantity }) => acc + (isIpAddon(addonOrPlanName) ? Quantity : 0),
811 export const getHasCoupon = (subscription: Subscription | undefined, coupon: string) => {
812 return [subscription?.CouponCode, subscription?.UpcomingSubscription?.CouponCode].includes(coupon);
815 export function isCancellableOnlyViaSupport(subscription: Subscription | undefined) {
816 const vpnB2BPlans = [PLANS.VPN_BUSINESS, PLANS.VPN_PRO];
817 const isVpnB2BPlan = vpnB2BPlans.includes(getPlanName(subscription) as PLANS);
822 const otherPlansWithIpAddons = [PLANS.BUNDLE_PRO, PLANS.BUNDLE_PRO_2024];
823 if (otherPlansWithIpAddons.includes(getPlanName(subscription) as PLANS)) {
824 const hasIpAddons = (Object.keys(getPlanIDs(subscription)) as (PLANS | ADDON_NAMES)[]).some((plan) =>
834 * Checks if subscription can be cancelled by a user. Cancellation means that the user will be downgraded at the end
835 * of the current billing cycle. In contrast, "Downgrade subscription" button means that the user will be downgraded
836 * immediately. Note that B2B subscriptions also have "Cancel subscription" button, but it behaves differently, so
837 * we don't consider B2B subscriptions cancellable for the purpose of this function.
839 export const hasCancellablePlan = (subscription: Subscription | undefined, user: UserModel) => {
840 if (isCancellableOnlyViaSupport(subscription)) {
844 // These plans are can be cancelled inhouse too
845 const cancellablePlan = getHasConsumerVpnPlan(subscription) || hasPass(subscription);
847 // In Chargebee, all plans are cancellable
848 const chargebeeForced = onSessionMigrationChargebeeStatus(user, subscription) === ChargebeeEnabled.CHARGEBEE_FORCED;
850 // Splitted users should go to PUT v4 renew because they still have an active subscription in inhouse system
851 // And we force them to do the renew cancellation instead of subscription deletion because this case is much
853 const splittedUser = isSplittedUser(user.ChargebeeUser, user.ChargebeeUserExists, subscription?.BillingPlatform);
855 return cancellablePlan || chargebeeForced || splittedUser;
858 export function hasMaximumCycle(subscription?: SubscriptionModel | FreeSubscription): boolean {
860 subscription?.Cycle === CYCLE.TWO_YEARS ||
861 subscription?.Cycle === CYCLE.THIRTY ||
862 subscription?.UpcomingSubscription?.Cycle === CYCLE.TWO_YEARS ||
863 subscription?.UpcomingSubscription?.Cycle === CYCLE.THIRTY
867 export const getMaximumCycleForApp = (app: ProductParam, currency?: Currency) => {
868 if (app === APPS.PROTONPASS || app === APPS.PROTONWALLET) {
871 // Even though this returns the same value as the final return, adding this explicitly because VPN wants to keep the two year cycle
872 if (app === APPS.PROTONVPN_SETTINGS) {
873 return CYCLE.TWO_YEARS;
876 return currency && isRegionalCurrency(currency) ? CYCLE.YEARLY : CYCLE.TWO_YEARS;
879 export const getPlanMaxIPs = (plan: Plan) => {
880 if (plan.Name === PLANS.VPN_BUSINESS) {
884 if (isIpAddon(plan.Name)) {
891 const getPlanMaxAIs = (plan: Plan) => {
892 return isScribeAddon(plan.Name) ? 1 : 0;
895 export const getMaxValue = (plan: Plan, key: MaxKeys): number => {
898 if (key === 'MaxIPs') {
899 result = getPlanMaxIPs(plan);
900 } else if (key === 'MaxAI') {
901 result = getPlanMaxAIs(plan);
909 export function getAddonMultiplier(addonMaxKey: MaxKeys, addon: Plan): number {
910 let addonMultiplier: number;
911 if (addonMaxKey === 'MaxIPs') {
912 addonMultiplier = getPlanMaxIPs(addon);
913 if (addonMultiplier === 0) {
917 addonMultiplier = getMaxValue(addon, addonMaxKey);
920 return addonMultiplier;
923 export function isTaxInclusive(checkResponse?: Pick<SubscriptionCheckResponse, 'TaxInclusive'>): boolean {
924 return checkResponse?.TaxInclusive === TaxInclusive.INCLUSIVE;
927 export const PASS_LAUNCH_OFFER = 'passlaunch';
929 export function hasPassLaunchOffer(subscription: Subscription | FreeSubscription | undefined): boolean {
930 if (!subscription || isFreeSubscription(subscription)) {
934 const plan = getPlan(subscription);
936 const isLaunchOffer = plan?.Offer === PASS_LAUNCH_OFFER;
938 return isLaunchOffer && subscription.Cycle === CYCLE.YEARLY && plan?.Name === PLANS.PASS;