Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / subscription.ts
blobf090797d072d77a1205f599a3fb42a885bc1ae5b
1 import { addWeeks, fromUnixTime, isBefore } from 'date-fns';
3 import {
4     ADDON_NAMES,
5     type Currency,
6     type MaxKeys,
7     PLANS,
8     PLAN_SERVICES,
9     PLAN_TYPES,
10     type PlanIDs,
11     isRegionalCurrency,
12     isSplittedUser,
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';
20 import type {
21     Organization,
22     Plan,
23     PlansMap,
24     Pricing,
25     Subscription,
26     SubscriptionCheckResponse,
27     SubscriptionModel,
28     SubscriptionPlan,
29     UserModel,
30 } from '../interfaces';
31 import { Audience, ChargebeeEnabled, External, TaxInclusive } from '../interfaces';
32 import { hasBit } from './bitset';
34 const { PLAN, ADDON } = PLAN_TYPES;
36 const {
37     VISIONARY,
38     MAIL,
39     MAIL_PRO,
40     MAIL_BUSINESS,
41     DRIVE,
42     DRIVE_PRO,
43     DRIVE_BUSINESS,
44     PASS,
45     WALLET,
46     VPN,
47     VPN2024,
48     VPN_PASS_BUNDLE,
49     ENTERPRISE,
50     BUNDLE,
51     BUNDLE_PRO,
52     BUNDLE_PRO_2024,
53     FAMILY,
54     DUO,
55     VPN_PRO,
56     VPN_BUSINESS,
57     PASS_PRO,
58     PASS_FAMILY,
59     PASS_BUSINESS,
60     PASS_LIFETIME,
61 } = PLANS;
63 const {
64     MEMBER_SCRIBE_MAILPLUS,
65     MEMBER_SCRIBE_MAIL_BUSINESS,
66     MEMBER_SCRIBE_DRIVEPLUS,
67     MEMBER_SCRIBE_BUNDLE,
68     MEMBER_SCRIBE_PASS,
69     MEMBER_SCRIBE_VPN,
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,
79     MEMBER_SCRIBE_FAMILY,
80     MEMBER_SCRIBE_DUO,
81 } = ADDON_NAMES;
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))
88     );
89     if (result) {
90         return result as SubscriptionPlan & { Name: PLANS };
91     }
92     return result;
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);
102     return plan?.Name;
105 export const getPlanTitle = (subscription: Subscription | undefined) => {
106     const plan = getPlan(subscription);
107     return plan?.Title;
110 export const hasSomePlan = (subscription: MaybeFreeSubscription, planName: PLANS) => {
111     if (isFreeSubscription(subscription)) {
112         return false;
113     }
115     return (subscription?.Plans || []).some(({ Name }) => Name === planName);
118 export const hasSomeAddonOrPlan = (
119     subscription: MaybeFreeSubscription,
120     addonName: ADDON_NAMES | PLANS | (ADDON_NAMES | PLANS)[]
121 ) => {
122     if (isFreeSubscription(subscription)) {
123         return false;
124     }
126     if (Array.isArray(addonName)) {
127         return (subscription?.Plans || []).some(({ Name }) => addonName.includes(Name as ADDON_NAMES));
128     }
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
143 ): boolean => {
144     if (!subscription || isFreeSubscription(subscription)) {
145         return false;
146     }
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,
184     MEMBER_SCRIBE_PASS,
185     MEMBER_SCRIBE_VPN,
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,
196     MEMBER_SCRIBE_DUO,
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)) {
210         switch (app) {
211             case APPS.PROTONPASS:
212                 return PLANS.PASS;
213             case APPS.PROTONDRIVE:
214                 return PLANS.DRIVE;
215             case APPS.PROTONVPN_SETTINGS:
216                 return PLANS.VPN;
217             case APPS.PROTONWALLET:
218                 return PLANS.WALLET;
219             default:
220             case APPS.PROTONMAIL:
221                 return PLANS.MAIL;
222         }
223     }
224     if (hasBundle(subscription) || hasBundlePro(subscription) || hasBundlePro2024(subscription)) {
225         return PLANS.BUNDLE_PRO_2024;
226     }
227     return PLANS.BUNDLE;
230 const b2bPlans: Set<PLANS | ADDON_NAMES> = new Set([
231     MAIL_PRO,
232     MAIL_BUSINESS,
233     DRIVE_PRO,
234     DRIVE_BUSINESS,
235     BUNDLE_PRO,
236     BUNDLE_PRO_2024,
237     ENTERPRISE,
238     VPN_PRO,
239     VPN_BUSINESS,
240     PASS_PRO,
241     PASS_BUSINESS,
243 export const getIsB2BAudienceFromPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
244     if (!planName) {
245         return false;
246     }
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([
257     VPN,
258     VPN2024,
259     WALLET,
260     PASS,
261     VPN_PASS_BUNDLE,
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) => {
272     if (!planName) {
273         return false;
274     }
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) => {
280     if (!planName) {
281         return false;
282     }
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) => {
288     if (!planName) {
289         return false;
290     }
291     return getIsPassB2BPlanCondition.has(planName);
294 const getIsPassPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
295     PASS,
296     PASS_FAMILY,
297     VPN_PASS_BUNDLE,
298     PASS_PRO,
299     PASS_BUSINESS,
301 export const getIsPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
302     if (!planName) {
303         return false;
304     }
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) => {
310     if (!planName) {
311         return false;
312     }
313     return consumerPassPlanSet.has(planName);
316 const getCanAccessDuoPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
317     PLANS.MAIL,
318     PLANS.DRIVE,
319     PLANS.PASS,
320     PLANS.PASS_FAMILY,
321     PLANS.VPN,
322     PLANS.VPN2024,
323     PLANS.BUNDLE,
324     PLANS.MAIL_PRO,
325     PLANS.VISIONARY,
326     PLANS.MAIL_BUSINESS,
327     PLANS.BUNDLE_PRO,
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) => {
336     return (
337         hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessPassFamilyPlanCondition.has(Name))
338     );
341 const getIsSentinelPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
342     VISIONARY,
343     BUNDLE,
344     FAMILY,
345     DUO,
346     BUNDLE_PRO,
347     BUNDLE_PRO_2024,
348     PASS,
349     PASS_FAMILY,
350     VPN_PASS_BUNDLE,
351     PASS_PRO,
352     PASS_BUSINESS,
353     MAIL_BUSINESS,
355 export const getIsSentinelPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
356     if (!planName) {
357         return false;
358     }
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) => {
364     if (!planName) {
365         return false;
366     }
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) => {
392     return (
393         hasVPN(subscription) ||
394         hasVPN2024(subscription) ||
395         hasVPNPassBundle(subscription) ||
396         hasVpnPro(subscription) ||
397         hasVpnBusiness(subscription)
398     );
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([
414     VPN_PRO,
415     VPN_BUSINESS,
416     DRIVE_PRO,
417     DRIVE_BUSINESS,
418     PASS_PRO,
419     PASS_BUSINESS,
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) => {
434     if (!subscription) {
435         return;
436     }
438     return getPlan(subscription);
441 export const getBaseAmount = (
442     name: PLANS | ADDON_NAMES,
443     plansMap: PlansMap,
444     subscription: Subscription | undefined,
445     cycle = CYCLE.MONTHLY
446 ) => {
447     const base = plansMap[name];
448     if (!base) {
449         return 0;
450     }
451     return (subscription?.Plans || [])
452         .filter(({ Name }) => Name === name)
453         .reduce((acc) => {
454             const pricePerCycle = base.Pricing[cycle] || 0;
455             return acc + pricePerCycle;
456         }, 0);
459 export const getPlanIDs = (subscription: MaybeFreeSubscription | null): PlanIDs => {
460     return (subscription?.Plans || []).reduce<PlanIDs>((acc, { Name, Quantity }) => {
461         acc[Name] = (acc[Name] || 0) + Quantity;
462         return acc;
463     }, {});
466 export const isTrial = (subscription: Subscription | FreeSubscription | undefined, plan?: PLANS): boolean => {
467     if (isFreeSubscription(subscription)) {
468         return false;
469     }
471     const isTrialV4 =
472         subscription?.CouponCode === COUPON_CODES.REFERRAL ||
473         subscription?.CouponCode === COUPON_CODES.MEMBER_DOWNGRADE_TRIAL;
474     const isTrialV5 = !!subscription?.IsTrial;
475     const trial = isTrialV4 || isTrialV5;
477     if (!plan) {
478         return trial;
479     }
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
497 ) => {
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 => {
508     if (!coupon) {
509         return false;
510     }
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 => {
522     if (!coupon) {
523         return false;
524     }
525     return blackFriday2024Discounts.has(coupon?.toUpperCase());
528 export const allCycles = Object.freeze(
529     Object.values(CYCLE)
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) => {
546     if (!subscription) {
547         return false;
548     }
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 {
556     if (!cycle) {
557         return undefined;
558     }
560     if (regularCycles.includes(cycle)) {
561         return cycle;
562     }
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) {
569             return regularCycle;
570         }
571     }
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 {
579     if (!cycle) {
580         return undefined;
581     }
582     if (cycle === CYCLE.MONTHLY) {
583         return CYCLE.YEARLY;
584     }
585     if (cycle === CYCLE.YEARLY) {
586         return CYCLE.TWO_YEARS;
587     }
589     if (cycle === CYCLE.FIFTEEN || cycle === CYCLE.THIRTY) {
590         return CYCLE.TWO_YEARS;
591     }
593     return cycle;
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;
630     /**
631      * That's pricing that counts only aggregate of cost for members. That's useful for rendering of
632      * "per user per month" pricing.
633      * Examples:
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`.
639      *
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.
645      */
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;
665     }
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];
673         if (!plan) {
674             return acc;
675         }
677         return acc + getPlanMembers(plan, quantity, view);
678     }, 0);
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];
690     }
692     if (cycle === CYCLE.YEARLY) {
693         return INCLUDED_IP_PRICING[CYCLE.YEARLY];
694     }
696     if (cycle === CYCLE.TWO_YEARS) {
697         return INCLUDED_IP_PRICING[CYCLE.TWO_YEARS];
698     }
700     return 0;
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.
710  */
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);
723     }
725     if (isMultiUserPersonalPlan(plan)) {
726         return totalPrice;
727     }
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) {
741             delete acc[cycle];
742         }
744         return acc;
745     }, {} as Pricing);
748 interface OfferResult {
749     pricing: Pricing;
750     cycles: CYCLE[];
751     valid: boolean;
754 export const getPlanOffer = (plan: Plan) => {
755     const result = [CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS].reduce<OfferResult>(
756         (acc, cycle) => {
757             acc.pricing[cycle] = (plan.DefaultPricing?.[cycle] ?? 0) - (getPricePerCycle(plan, cycle) ?? 0);
758             return acc;
759         },
760         {
761             valid: false,
762             cycles: [],
763             pricing: {
764                 [CYCLE.MONTHLY]: 0,
765                 [CYCLE.YEARLY]: 0,
766                 [CYCLE.THREE]: 0,
767                 [CYCLE.TWO_YEARS]: 0,
768                 [CYCLE.FIFTEEN]: 0,
769                 [CYCLE.EIGHTEEN]: 0,
770                 [CYCLE.THIRTY]: 0,
771             },
772         }
773     );
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) {
777         result.valid = true;
778     }
779     return result;
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,
786 } as const;
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.
792  */
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
797     if (!planName) {
798         return 0;
799     }
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),
807         includedIPs
808     );
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);
818     if (isVpnB2BPlan) {
819         return true;
820     }
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) =>
825             isIpAddon(plan)
826         );
827         return hasIpAddons;
828     }
830     return false;
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.
838  */
839 export const hasCancellablePlan = (subscription: Subscription | undefined, user: UserModel) => {
840     if (isCancellableOnlyViaSupport(subscription)) {
841         return false;
842     }
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
852     // simpler to handle
853     const splittedUser = isSplittedUser(user.ChargebeeUser, user.ChargebeeUserExists, subscription?.BillingPlatform);
855     return cancellablePlan || chargebeeForced || splittedUser;
858 export function hasMaximumCycle(subscription?: SubscriptionModel | FreeSubscription): boolean {
859     return (
860         subscription?.Cycle === CYCLE.TWO_YEARS ||
861         subscription?.Cycle === CYCLE.THIRTY ||
862         subscription?.UpcomingSubscription?.Cycle === CYCLE.TWO_YEARS ||
863         subscription?.UpcomingSubscription?.Cycle === CYCLE.THIRTY
864     );
867 export const getMaximumCycleForApp = (app: ProductParam, currency?: Currency) => {
868     if (app === APPS.PROTONPASS || app === APPS.PROTONWALLET) {
869         return CYCLE.YEARLY;
870     }
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;
874     }
876     return currency && isRegionalCurrency(currency) ? CYCLE.YEARLY : CYCLE.TWO_YEARS;
879 export const getPlanMaxIPs = (plan: Plan) => {
880     if (plan.Name === PLANS.VPN_BUSINESS) {
881         return 1;
882     }
884     if (isIpAddon(plan.Name)) {
885         return 1;
886     }
888     return 0;
891 const getPlanMaxAIs = (plan: Plan) => {
892     return isScribeAddon(plan.Name) ? 1 : 0;
895 export const getMaxValue = (plan: Plan, key: MaxKeys): number => {
896     let result: number;
898     if (key === 'MaxIPs') {
899         result = getPlanMaxIPs(plan);
900     } else if (key === 'MaxAI') {
901         result = getPlanMaxAIs(plan);
902     } else {
903         result = plan[key];
904     }
906     return result ?? 0;
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) {
914             addonMultiplier = 1;
915         }
916     } else {
917         addonMultiplier = getMaxValue(addon, addonMaxKey);
918     }
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)) {
931         return false;
932     }
934     const plan = getPlan(subscription);
936     const isLaunchOffer = plan?.Offer === PASS_LAUNCH_OFFER;
938     return isLaunchOffer && subscription.Cycle === CYCLE.YEARLY && plan?.Name === PLANS.PASS;