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;
63 const { MEMBER_SCRIBE_MAIL_BUSINESS, MEMBER_SCRIBE_MAIL_PRO, MEMBER_SCRIBE_BUNDLE_PRO, MEMBER_SCRIBE_BUNDLE_PRO_2024 } =
66 type MaybeFreeSubscription = Subscription | FreeSubscription | undefined;
68 export const getPlan = (subscription: Subscription | FreeSubscription | undefined, service?: PLAN_SERVICES) => {
69 const result = (subscription?.Plans || []).find(
70 ({ Services, Type }) => Type === PLAN && (service === undefined ? true : hasBit(Services, service))
73 return result as SubscriptionPlan & { Name: PLANS };
78 export const getAddons = (subscription: Subscription | undefined) =>
79 (subscription?.Plans || []).filter(({ Type }) => Type === ADDON);
80 export const hasAddons = (subscription: Subscription | undefined) =>
81 (subscription?.Plans || []).some(({ Type }) => Type === ADDON);
83 export const getPlanName = (subscription: Subscription | FreeSubscription | undefined, service?: PLAN_SERVICES) => {
84 const plan = getPlan(subscription, service);
88 export const getPlanTitle = (subscription: Subscription | undefined) => {
89 const plan = getPlan(subscription);
93 export const hasSomePlan = (subscription: MaybeFreeSubscription, planName: PLANS) => {
94 if (isFreeSubscription(subscription)) {
98 return (subscription?.Plans || []).some(({ Name }) => Name === planName);
101 export const hasSomeAddonOrPlan = (
102 subscription: MaybeFreeSubscription,
103 addonName: ADDON_NAMES | PLANS | (ADDON_NAMES | PLANS)[]
105 if (isFreeSubscription(subscription)) {
109 if (Array.isArray(addonName)) {
110 return (subscription?.Plans || []).some(({ Name }) => addonName.includes(Name as ADDON_NAMES));
113 return (subscription?.Plans || []).some(({ Name }) => Name === addonName);
116 export const hasLifetimeCoupon = (subscription: Subscription | FreeSubscription | undefined) => {
117 return subscription?.CouponCode === COUPON_CODES.LIFETIME;
120 export const hasMigrationDiscount = (subscription?: Subscription) => {
121 return subscription?.CouponCode?.startsWith('MIGRATION');
124 export const isManagedExternally = (
125 subscription: Subscription | FreeSubscription | Pick<Subscription, 'External'> | undefined | null
127 if (!subscription || isFreeSubscription(subscription)) {
131 return subscription.External === External.Android || subscription.External === External.iOS;
134 export const hasVisionary = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VISIONARY);
135 export const hasVPN = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN);
136 export const hasVPN2024 = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN2024);
137 export const hasVPNPassBundle = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_PASS_BUNDLE);
138 export const hasMail = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL);
139 export const hasMailPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL_PRO);
140 export const hasMailBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, MAIL_BUSINESS);
141 export const hasDrive = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE);
142 export const hasDrivePro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE_PRO);
143 export const hasDriveBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DRIVE_BUSINESS);
144 export const hasPass = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS);
145 export const hasWallet = (subscription: MaybeFreeSubscription) => hasSomeAddonOrPlan(subscription, WALLET);
146 export const hasEnterprise = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, ENTERPRISE);
147 export const hasBundle = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE);
148 export const hasBundlePro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE_PRO);
149 export const hasBundlePro2024 = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, BUNDLE_PRO_2024);
150 export const hasFamily = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, FAMILY);
151 export const hasDuo = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, DUO);
152 export const hasVpnPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_PRO);
153 export const hasVpnBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, VPN_BUSINESS);
154 export const hasPassPro = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_PRO);
155 export const hasPassFamily = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_FAMILY);
156 export const hasPassBusiness = (subscription: MaybeFreeSubscription) => hasSomePlan(subscription, PASS_BUSINESS);
157 export const hasFree = (subscription: MaybeFreeSubscription) => (subscription?.Plans || []).length === 0;
159 export const hasAnyBundlePro = (subscription: MaybeFreeSubscription) =>
160 hasBundlePro(subscription) || hasBundlePro2024(subscription);
162 const hasAIAssistantCondition = [
163 MEMBER_SCRIBE_MAIL_BUSINESS,
164 MEMBER_SCRIBE_MAIL_PRO,
165 MEMBER_SCRIBE_BUNDLE_PRO,
166 MEMBER_SCRIBE_BUNDLE_PRO_2024,
168 export const hasAIAssistant = (subscription: MaybeFreeSubscription) =>
169 hasSomeAddonOrPlan(subscription, hasAIAssistantCondition);
171 export const PLANS_WITH_AI_INCLUDED = [VISIONARY, DUO, FAMILY];
172 export const hasPlanWithAIAssistantIncluded = (subscription: MaybeFreeSubscription) =>
173 hasSomeAddonOrPlan(subscription, PLANS_WITH_AI_INCLUDED);
175 export const hasAllProductsB2CPlan = (subscription: MaybeFreeSubscription) =>
176 hasDuo(subscription) || hasFamily(subscription) || hasBundle(subscription) || hasVisionary(subscription);
178 export const getUpgradedPlan = (subscription: Subscription | undefined, app: ProductParam) => {
179 if (hasFree(subscription)) {
181 case APPS.PROTONPASS:
183 case APPS.PROTONDRIVE:
185 case APPS.PROTONVPN_SETTINGS:
187 case APPS.PROTONWALLET:
190 case APPS.PROTONMAIL:
194 if (hasBundle(subscription) || hasBundlePro(subscription) || hasBundlePro2024(subscription)) {
195 return PLANS.BUNDLE_PRO_2024;
200 const b2bPlans: Set<PLANS | ADDON_NAMES> = new Set([
213 export const getIsB2BAudienceFromPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
218 return b2bPlans.has(planName);
221 const canCheckItemPaidChecklistCondition: Set<PLANS | ADDON_NAMES> = new Set([MAIL, DRIVE, FAMILY, DUO, BUNDLE]);
222 export const canCheckItemPaidChecklist = (subscription: Subscription | undefined) => {
223 return subscription?.Plans?.some(({ Name }) => canCheckItemPaidChecklistCondition.has(Name));
226 const canCheckItemGetStartedCondition: Set<PLANS | ADDON_NAMES> = new Set([
233 export const canCheckItemGetStarted = (subscription: Subscription | undefined) => {
234 return subscription?.Plans?.some(({ Name }) => canCheckItemGetStartedCondition.has(Name));
237 const getIsVpnB2BPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN_PRO, VPN_BUSINESS]);
238 export const getIsVpnB2BPlan = (planName: PLANS | ADDON_NAMES) => getIsVpnB2BPlanCondition.has(planName);
240 const getIsVpnPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN, VPN2024, VPN_PASS_BUNDLE, VPN_PRO, VPN_BUSINESS]);
241 export const getIsVpnPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
245 return getIsVpnPlanCondition.has(planName);
248 const getIsConsumerVpnPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([VPN, VPN2024, VPN_PASS_BUNDLE]);
249 export const getIsConsumerVpnPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
253 return getIsConsumerVpnPlanCondition.has(planName);
256 const getIsPassB2BPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([PASS_PRO, PASS_BUSINESS]);
257 export const getIsPassB2BPlan = (planName?: PLANS | ADDON_NAMES) => {
261 return getIsPassB2BPlanCondition.has(planName);
264 const getIsPassPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
271 export const getIsPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
275 return getIsPassPlanCondition.has(planName);
278 const consumerPassPlanSet: Set<PLANS | ADDON_NAMES> = new Set([PASS, PASS_FAMILY, VPN_PASS_BUNDLE]);
279 export const getIsConsumerPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
283 return consumerPassPlanSet.has(planName);
286 const getCanAccessDuoPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
298 PLANS.BUNDLE_PRO_2024,
300 export const getCanSubscriptionAccessDuoPlan = (subscription?: MaybeFreeSubscription) => {
301 return hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessDuoPlanCondition.has(Name));
304 const getCanAccessPassFamilyPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([PLANS.PASS]);
305 export const getCanSubscriptionAccessPassFamilyPlan = (subscription?: MaybeFreeSubscription) => {
307 hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessPassFamilyPlanCondition.has(Name))
311 const getIsSentinelPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
325 export const getIsSentinelPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
329 return getIsSentinelPlanCondition.has(planName);
332 const lifetimePlans: Set<PLANS | ADDON_NAMES> = new Set([PASS_LIFETIME]);
333 export const isLifetimePlan = (planName: PLANS | ADDON_NAMES | undefined) => {
338 return lifetimePlans.has(planName);
341 export const getIsB2BAudienceFromSubscription = (subscription: Subscription | undefined) => {
342 return !!subscription?.Plans?.some(({ Name }) => getIsB2BAudienceFromPlan(Name));
345 export const getHasVpnB2BPlan = (subscription: MaybeFreeSubscription) => {
346 return hasVpnPro(subscription) || hasVpnBusiness(subscription);
349 export const appSupportsSSO = (appName?: APP_NAMES) => {
350 return appName && [APPS.PROTONVPN_SETTINGS, APPS.PROTONPASS].some((ssoPlanName) => ssoPlanName === appName);
353 export const planSupportsSSO = (planName?: PLANS) => {
354 return planName && [PLANS.VPN_BUSINESS, PLANS.PASS_BUSINESS].some((ssoPlanName) => ssoPlanName === planName);
357 export const upsellPlanSSO = (planName?: PLANS) => {
358 return planName && [PLANS.VPN_PRO, PLANS.PASS_PRO].some((ssoPlanName) => ssoPlanName === planName);
361 export const getHasSomeVpnPlan = (subscription: MaybeFreeSubscription) => {
363 hasVPN(subscription) ||
364 hasVPN2024(subscription) ||
365 hasVPNPassBundle(subscription) ||
366 hasVpnPro(subscription) ||
367 hasVpnBusiness(subscription)
371 export const getHasConsumerVpnPlan = (subscription: MaybeFreeSubscription) => {
372 return hasVPN(subscription) || hasVPN2024(subscription) || hasVPNPassBundle(subscription);
375 export const getHasPassB2BPlan = (subscription: MaybeFreeSubscription) => {
376 return hasPassPro(subscription) || hasPassBusiness(subscription);
379 export const getHasDriveB2BPlan = (subscription: MaybeFreeSubscription) => {
380 return hasDrivePro(subscription) || hasDriveBusiness(subscription);
383 const externalMemberB2BPlans: Set<PLANS | ADDON_NAMES> = new Set([
391 export const getHasExternalMemberCapableB2BPlan = (subscription: MaybeFreeSubscription) => {
392 return subscription?.Plans?.some((plan) => externalMemberB2BPlans.has(plan.Name)) || false;
395 export const getHasMailB2BPlan = (subscription: MaybeFreeSubscription) => {
396 return hasMailPro(subscription) || hasMailBusiness(subscription);
399 export const getHasInboxB2BPlan = (subscription: MaybeFreeSubscription) => {
400 return hasAnyBundlePro(subscription) || getHasMailB2BPlan(subscription);
403 export const getPrimaryPlan = (subscription: Subscription | undefined) => {
408 return getPlan(subscription);
411 export const getBaseAmount = (
412 name: PLANS | ADDON_NAMES,
414 subscription: Subscription | undefined,
415 cycle = CYCLE.MONTHLY
417 const base = plansMap[name];
421 return (subscription?.Plans || [])
422 .filter(({ Name }) => Name === name)
424 const pricePerCycle = base.Pricing[cycle] || 0;
425 return acc + pricePerCycle;
429 export const getPlanIDs = (subscription: MaybeFreeSubscription | null): PlanIDs => {
430 return (subscription?.Plans || []).reduce<PlanIDs>((acc, { Name, Quantity }) => {
431 acc[Name] = (acc[Name] || 0) + Quantity;
436 export const isTrial = (subscription: Subscription | FreeSubscription | undefined, plan?: PLANS): boolean => {
437 if (isFreeSubscription(subscription)) {
442 subscription?.CouponCode === COUPON_CODES.REFERRAL ||
443 subscription?.CouponCode === COUPON_CODES.MEMBER_DOWNGRADE_TRIAL;
444 const isTrialV5 = !!subscription?.IsTrial;
445 const trial = isTrialV4 || isTrialV5;
451 return trial && getPlanName(subscription) === plan;
454 export const isTrialExpired = (subscription: Subscription | undefined) => {
455 const now = new Date();
456 return now > fromUnixTime(subscription?.PeriodEnd || 0);
459 export const willTrialExpire = (subscription: Subscription | undefined) => {
460 const now = new Date();
461 return isBefore(fromUnixTime(subscription?.PeriodEnd || 0), addWeeks(now, 1));
464 export const getHasMemberCapablePlan = (
465 organization: Organization | undefined,
466 subscription: Subscription | undefined
468 const supportedAddons = getSupportedAddons(getPlanIDs(subscription));
469 return (organization?.MaxMembers || 0) > 1 || (Object.keys(supportedAddons) as ADDON_NAMES[]).some(isMemberAddon);
472 const endOfYearDiscountCoupons: Set<string> = new Set([
473 COUPON_CODES.END_OF_YEAR_2023,
474 COUPON_CODES.BLACK_FRIDAY_2023,
475 COUPON_CODES.EOY_2023_1M_INTRO,
477 export const getHas2023OfferCoupon = (coupon: string | undefined | null): boolean => {
481 return endOfYearDiscountCoupons.has(coupon);
483 const blackFriday2024Discounts: Set<string> = new Set([
484 COUPON_CODES.BLACK_FRIDAY_2024,
485 COUPON_CODES.BLACK_FRIDAY_2024_MONTH,
486 COUPON_CODES.BLACK_FRIDAY_2024_PCMAG,
487 COUPON_CODES.BLACK_FRIDAY_2024_HB,
488 COUPON_CODES.BLACK_FRIDAY_2024_VPNLIGHTNING,
489 COUPON_CODES.BLACK_FRIDAY_2024_PASS_LIFE,
491 export const getHas2024OfferCoupon = (coupon: string | undefined | null): boolean => {
495 return blackFriday2024Discounts.has(coupon?.toUpperCase());
498 export const allCycles = Object.freeze(
500 .filter((cycle): cycle is CYCLE => typeof cycle === 'number')
501 .sort((a, b) => a - b)
503 export const regularCycles = Object.freeze([CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS]);
504 export const customCycles = Object.freeze(allCycles.filter((cycle) => !regularCycles.includes(cycle)));
506 export const getValidCycle = (cycle: number): CYCLE | undefined => {
507 return allCycles.includes(cycle) ? cycle : undefined;
510 const getValidAudienceCondition = [Audience.B2B, Audience.B2C, Audience.FAMILY];
511 export const getValidAudience = (audience: string | undefined | null): Audience | undefined => {
512 return getValidAudienceCondition.find((realAudience) => realAudience === audience);
515 export const getIsCustomCycle = (subscription?: Subscription) => {
519 return customCycles.includes(subscription.Cycle);
522 export function getNormalCycleFromCustomCycle(cycle: CYCLE): CYCLE;
523 export function getNormalCycleFromCustomCycle(cycle: undefined): undefined;
524 export function getNormalCycleFromCustomCycle(cycle: CYCLE | undefined): CYCLE | undefined;
525 export function getNormalCycleFromCustomCycle(cycle: CYCLE | undefined): CYCLE | undefined {
530 if (regularCycles.includes(cycle)) {
534 // find the closest lower regular cycle
535 for (let i = regularCycles.length - 1; i >= 0; i--) {
536 const regularCycle = regularCycles[i];
538 if (regularCycle < cycle) {
543 // well, that should be unreachable, but let it be just in case
544 return CYCLE.MONTHLY;
547 export function getLongerCycle(cycle: CYCLE): CYCLE;
548 export function getLongerCycle(cycle: CYCLE | undefined): CYCLE | undefined {
552 if (cycle === CYCLE.MONTHLY) {
555 if (cycle === CYCLE.YEARLY) {
556 return CYCLE.TWO_YEARS;
559 if (cycle === CYCLE.FIFTEEN || cycle === CYCLE.THIRTY) {
560 return CYCLE.TWO_YEARS;
566 export const hasYearly = (subscription?: Subscription) => {
567 return subscription?.Cycle === CYCLE.YEARLY;
570 export const hasMonthly = (subscription?: Subscription) => {
571 return subscription?.Cycle === CYCLE.MONTHLY;
574 export const hasTwoYears = (subscription?: Subscription) => {
575 return subscription?.Cycle === CYCLE.TWO_YEARS;
578 export const hasFifteen = (subscription?: Subscription) => {
579 return subscription?.Cycle === CYCLE.FIFTEEN;
582 export const hasThirty = (subscription?: Subscription) => {
583 return subscription?.Cycle === CYCLE.THIRTY;
586 export interface PricingForCycles {
587 [CYCLE.MONTHLY]: number;
588 [CYCLE.THREE]: number;
589 [CYCLE.YEARLY]: number;
590 [CYCLE.EIGHTEEN]: number;
591 [CYCLE.TWO_YEARS]: number;
592 [CYCLE.FIFTEEN]: number;
593 [CYCLE.THIRTY]: number;
596 export interface AggregatedPricing {
597 all: PricingForCycles;
598 defaultMonthlyPrice: number;
599 defaultMonthlyPriceWithoutAddons: number;
601 * That's pricing that counts only aggregate of cost for members. That's useful for rendering of
602 * "per user per month" pricing.
604 * - If you have a B2C plan with 1 user, then this price will be the same as `all`.
605 * - If you have Mail Plus plan with several users, then this price will be the same as `all`, because each
606 * additional member counts to the price of members.
607 * - If you have Bundle Pro with several users and with the default (minimum) number of custom domains, then
608 * this price will be the same as `all`.
610 * Here things become different:
611 * - If you have Bundle Pro with several users and with more than the default (minimum) number of custom domains,
612 * then this price will be `all - extra custom domains price`.
613 * - For VPN Business the behavior is more complex. It also has two addons: member and IPs/servers. By default it
614 * has 2 members and 1 IP. The price for members should exclude price for the 1 default IP.
616 members: PricingForCycles;
617 membersNumber: number;
618 plans: PricingForCycles;
621 function isMultiUserPersonalPlan(plan: Plan) {
622 // even though Duo, Family and Visionary plans can have up to 6 users in the org,
623 // for the price displaying purposes we count it as 1 member.
624 return plan.Name === PLANS.DUO || plan.Name === PLANS.FAMILY || plan.Name === PLANS.VISIONARY;
627 export function getPlanMembers(plan: Plan, quantity: number, view = true): number {
628 const hasMembers = plan.Type === PLAN_TYPES.PLAN || (plan.Type === PLAN_TYPES.ADDON && isMemberAddon(plan.Name));
630 let membersNumberInPlan = 0;
631 if (isMultiUserPersonalPlan(plan) && view) {
632 membersNumberInPlan = 1;
633 } else if (hasMembers) {
634 membersNumberInPlan = plan.MaxMembers || 1;
637 return membersNumberInPlan * quantity;
640 export function getMembersFromPlanIDs(planIDs: PlanIDs, plansMap: PlansMap, view = true): number {
641 return (Object.entries(planIDs) as [PLANS | ADDON_NAMES, number][]).reduce((acc, [name, quantity]) => {
642 const plan = plansMap[name];
647 return acc + getPlanMembers(plan, quantity, view);
651 export const INCLUDED_IP_PRICING = {
652 [CYCLE.MONTHLY]: 4999,
653 [CYCLE.YEARLY]: 3999 * CYCLE.YEARLY,
654 [CYCLE.TWO_YEARS]: 3599 * CYCLE.TWO_YEARS,
657 function getIpPrice(cycle: CYCLE): number {
658 if (cycle === CYCLE.MONTHLY) {
659 return INCLUDED_IP_PRICING[CYCLE.MONTHLY];
662 if (cycle === CYCLE.YEARLY) {
663 return INCLUDED_IP_PRICING[CYCLE.YEARLY];
666 if (cycle === CYCLE.TWO_YEARS) {
667 return INCLUDED_IP_PRICING[CYCLE.TWO_YEARS];
673 export function getIpPricePerMonth(cycle: CYCLE): number {
674 return getIpPrice(cycle) / cycle;
678 * The purpose of this overridden price is to show a coupon discount in the cycle selector. If that would be supported
679 * this would not be needed.
681 export const getPricePerCycle = (plan: Plan | undefined, cycle: CYCLE) => {
682 return plan?.Pricing?.[cycle];
685 export function getPricePerMember(plan: Plan, cycle: CYCLE): number {
686 const totalPrice = getPricePerCycle(plan, cycle) || 0;
688 if (plan.Name === PLANS.VPN_BUSINESS) {
689 // For VPN business, we exclude IP price from calculation. And we also divide by 2,
690 // because it has 2 members by default too.
691 const IP_PRICE = getIpPrice(cycle);
692 return (totalPrice - IP_PRICE) / (plan.MaxMembers || 1);
695 if (isMultiUserPersonalPlan(plan)) {
699 // Some plans have 0 MaxMembers. That's because they don't have access to mail.
700 // In reality, they still get 1 member.
701 return totalPrice / (plan.MaxMembers || 1);
704 export function getPricingPerMember(plan: Plan): Pricing {
705 return allCycles.reduce((acc, cycle) => {
706 acc[cycle] = getPricePerMember(plan, cycle);
708 // If the plan doesn't have custom cycles, we need to remove it from the resulting Pricing object
709 const isNonDefinedCycle = acc[cycle] === undefined || acc[cycle] === null || acc[cycle] === 0;
710 if (customCycles.includes(cycle) && isNonDefinedCycle) {
718 interface OfferResult {
724 export const getPlanOffer = (plan: Plan) => {
725 const result = [CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS].reduce<OfferResult>(
727 acc.pricing[cycle] = (plan.DefaultPricing?.[cycle] ?? 0) - (getPricePerCycle(plan, cycle) ?? 0);
737 [CYCLE.TWO_YEARS]: 0,
744 const sortedResults = (Object.entries(result.pricing) as unknown as [CYCLE, number][]).sort((a, b) => b[1] - a[1]);
745 result.cycles = sortedResults.map(([cycle]) => cycle);
746 if (sortedResults[0][1] > 0) {
752 const IPS_INCLUDED_IN_PLAN: Partial<Record<PLANS, number>> = {
753 [PLANS.VPN_BUSINESS]: 1,
754 [PLANS.BUNDLE_PRO]: 0,
755 [PLANS.BUNDLE_PRO_2024]: 0,
759 * Currently there is no convenient way to get the number of IPs for a VPN subscription.
760 * There is no dedicated field for that in the API.
761 * That's a hack that counts the number of IP addons.
763 export const getVPNDedicatedIPs = (subscription: Subscription | undefined) => {
764 const planName = getPlanName(subscription, PLAN_SERVICES.VPN);
766 // If you have other VPN plans, they don't have dedicated IPs
771 // Some plans might have included IPs without any indication on the backend.
772 // For example, 1 IP is included in the Business plan
773 const includedIPs = IPS_INCLUDED_IN_PLAN[planName] || 0;
775 return (subscription as Subscription).Plans.reduce(
776 (acc, { Name: addonOrPlanName, Quantity }) => acc + (isIpAddon(addonOrPlanName) ? Quantity : 0),
781 export const getHasCoupon = (subscription: Subscription | undefined, coupon: string) => {
782 return [subscription?.CouponCode, subscription?.UpcomingSubscription?.CouponCode].includes(coupon);
785 export function isCancellableOnlyViaSupport(subscription: Subscription | undefined) {
786 const vpnB2BPlans = [PLANS.VPN_BUSINESS, PLANS.VPN_PRO];
787 const isVpnB2BPlan = vpnB2BPlans.includes(getPlanName(subscription) as PLANS);
792 const otherPlansWithIpAddons = [PLANS.BUNDLE_PRO, PLANS.BUNDLE_PRO_2024];
793 if (otherPlansWithIpAddons.includes(getPlanName(subscription) as PLANS)) {
794 const hasIpAddons = (Object.keys(getPlanIDs(subscription)) as (PLANS | ADDON_NAMES)[]).some((plan) =>
804 * Checks if subscription can be cancelled by a user. Cancellation means that the user will be downgraded at the end
805 * of the current billing cycle. In contrast, "Downgrade subscription" button means that the user will be downgraded
806 * immediately. Note that B2B subscriptions also have "Cancel subscription" button, but it behaves differently, so
807 * we don't consider B2B subscriptions cancellable for the purpose of this function.
809 export const hasCancellablePlan = (subscription: Subscription | undefined, user: UserModel) => {
810 if (isCancellableOnlyViaSupport(subscription)) {
814 // These plans are can be cancelled inhouse too
815 const cancellablePlan = getHasConsumerVpnPlan(subscription) || hasPass(subscription);
817 // In Chargebee, all plans are cancellable
818 const chargebeeForced = onSessionMigrationChargebeeStatus(user, subscription) === ChargebeeEnabled.CHARGEBEE_FORCED;
820 // Splitted users should go to PUT v4 renew because they still have an active subscription in inhouse system
821 // And we force them to do the renew cancellation instead of subscription deletion because this case is much
823 const splittedUser = isSplittedUser(user.ChargebeeUser, user.ChargebeeUserExists, subscription?.BillingPlatform);
825 return cancellablePlan || chargebeeForced || splittedUser;
828 export function hasMaximumCycle(subscription?: SubscriptionModel | FreeSubscription): boolean {
830 subscription?.Cycle === CYCLE.TWO_YEARS ||
831 subscription?.Cycle === CYCLE.THIRTY ||
832 subscription?.UpcomingSubscription?.Cycle === CYCLE.TWO_YEARS ||
833 subscription?.UpcomingSubscription?.Cycle === CYCLE.THIRTY
837 export const getMaximumCycleForApp = (app: ProductParam, currency?: Currency) => {
838 if (app === APPS.PROTONPASS || app === APPS.PROTONWALLET) {
841 // Even though this returns the same value as the final return, adding this explicitly because VPN wants to keep the two year cycle
842 if (app === APPS.PROTONVPN_SETTINGS) {
843 return CYCLE.TWO_YEARS;
846 return currency && isRegionalCurrency(currency) ? CYCLE.YEARLY : CYCLE.TWO_YEARS;
849 export const getPlanMaxIPs = (plan: Plan) => {
850 if (plan.Name === PLANS.VPN_BUSINESS) {
854 if (isIpAddon(plan.Name)) {
861 const getPlanMaxAIs = (plan: Plan) => {
862 return isScribeAddon(plan.Name) ? 1 : 0;
865 export const getMaxValue = (plan: Plan, key: MaxKeys): number => {
868 if (key === 'MaxIPs') {
869 result = getPlanMaxIPs(plan);
870 } else if (key === 'MaxAI') {
871 result = getPlanMaxAIs(plan);
879 export function getAddonMultiplier(addonMaxKey: MaxKeys, addon: Plan): number {
880 let addonMultiplier: number;
881 if (addonMaxKey === 'MaxIPs') {
882 addonMultiplier = getPlanMaxIPs(addon);
883 if (addonMultiplier === 0) {
887 addonMultiplier = getMaxValue(addon, addonMaxKey);
890 return addonMultiplier;
893 export function isTaxInclusive(checkResponse?: Pick<SubscriptionCheckResponse, 'TaxInclusive'>): boolean {
894 return checkResponse?.TaxInclusive === TaxInclusive.INCLUSIVE;
897 export const PASS_LAUNCH_OFFER = 'passlaunch';
899 export function hasPassLaunchOffer(subscription: Subscription | FreeSubscription | undefined): boolean {
900 if (!subscription || isFreeSubscription(subscription)) {
904 const plan = getPlan(subscription);
906 const isLaunchOffer = plan?.Offer === PASS_LAUNCH_OFFER;
908 return isLaunchOffer && subscription.Cycle === CYCLE.YEARLY && plan?.Name === PLANS.PASS;