Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / helpers / subscription.ts
blob4dbcd2b1040d79069ab29cd7144591d8ea39dd71
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 { MEMBER_SCRIBE_MAIL_BUSINESS, MEMBER_SCRIBE_MAIL_PRO, MEMBER_SCRIBE_BUNDLE_PRO, MEMBER_SCRIBE_BUNDLE_PRO_2024 } =
64     ADDON_NAMES;
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))
71     );
72     if (result) {
73         return result as SubscriptionPlan & { Name: PLANS };
74     }
75     return result;
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);
85     return plan?.Name;
88 export const getPlanTitle = (subscription: Subscription | undefined) => {
89     const plan = getPlan(subscription);
90     return plan?.Title;
93 export const hasSomePlan = (subscription: MaybeFreeSubscription, planName: PLANS) => {
94     if (isFreeSubscription(subscription)) {
95         return false;
96     }
98     return (subscription?.Plans || []).some(({ Name }) => Name === planName);
101 export const hasSomeAddonOrPlan = (
102     subscription: MaybeFreeSubscription,
103     addonName: ADDON_NAMES | PLANS | (ADDON_NAMES | PLANS)[]
104 ) => {
105     if (isFreeSubscription(subscription)) {
106         return false;
107     }
109     if (Array.isArray(addonName)) {
110         return (subscription?.Plans || []).some(({ Name }) => addonName.includes(Name as ADDON_NAMES));
111     }
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
126 ): boolean => {
127     if (!subscription || isFreeSubscription(subscription)) {
128         return false;
129     }
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)) {
180         switch (app) {
181             case APPS.PROTONPASS:
182                 return PLANS.PASS;
183             case APPS.PROTONDRIVE:
184                 return PLANS.DRIVE;
185             case APPS.PROTONVPN_SETTINGS:
186                 return PLANS.VPN;
187             case APPS.PROTONWALLET:
188                 return PLANS.WALLET;
189             default:
190             case APPS.PROTONMAIL:
191                 return PLANS.MAIL;
192         }
193     }
194     if (hasBundle(subscription) || hasBundlePro(subscription) || hasBundlePro2024(subscription)) {
195         return PLANS.BUNDLE_PRO_2024;
196     }
197     return PLANS.BUNDLE;
200 const b2bPlans: Set<PLANS | ADDON_NAMES> = new Set([
201     MAIL_PRO,
202     MAIL_BUSINESS,
203     DRIVE_PRO,
204     DRIVE_BUSINESS,
205     BUNDLE_PRO,
206     BUNDLE_PRO_2024,
207     ENTERPRISE,
208     VPN_PRO,
209     VPN_BUSINESS,
210     PASS_PRO,
211     PASS_BUSINESS,
213 export const getIsB2BAudienceFromPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
214     if (!planName) {
215         return false;
216     }
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([
227     VPN,
228     VPN2024,
229     WALLET,
230     PASS,
231     VPN_PASS_BUNDLE,
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) => {
242     if (!planName) {
243         return false;
244     }
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) => {
250     if (!planName) {
251         return false;
252     }
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) => {
258     if (!planName) {
259         return false;
260     }
261     return getIsPassB2BPlanCondition.has(planName);
264 const getIsPassPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
265     PASS,
266     PASS_FAMILY,
267     VPN_PASS_BUNDLE,
268     PASS_PRO,
269     PASS_BUSINESS,
271 export const getIsPassPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
272     if (!planName) {
273         return false;
274     }
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) => {
280     if (!planName) {
281         return false;
282     }
283     return consumerPassPlanSet.has(planName);
286 const getCanAccessDuoPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
287     PLANS.MAIL,
288     PLANS.DRIVE,
289     PLANS.PASS,
290     PLANS.PASS_FAMILY,
291     PLANS.VPN,
292     PLANS.VPN2024,
293     PLANS.BUNDLE,
294     PLANS.MAIL_PRO,
295     PLANS.VISIONARY,
296     PLANS.MAIL_BUSINESS,
297     PLANS.BUNDLE_PRO,
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) => {
306     return (
307         hasFree(subscription) || subscription?.Plans?.some(({ Name }) => getCanAccessPassFamilyPlanCondition.has(Name))
308     );
311 const getIsSentinelPlanCondition: Set<PLANS | ADDON_NAMES> = new Set([
312     VISIONARY,
313     BUNDLE,
314     FAMILY,
315     DUO,
316     BUNDLE_PRO,
317     BUNDLE_PRO_2024,
318     PASS,
319     PASS_FAMILY,
320     VPN_PASS_BUNDLE,
321     PASS_PRO,
322     PASS_BUSINESS,
323     MAIL_BUSINESS,
325 export const getIsSentinelPlan = (planName: PLANS | ADDON_NAMES | undefined) => {
326     if (!planName) {
327         return false;
328     }
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) => {
334     if (!planName) {
335         return false;
336     }
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) => {
362     return (
363         hasVPN(subscription) ||
364         hasVPN2024(subscription) ||
365         hasVPNPassBundle(subscription) ||
366         hasVpnPro(subscription) ||
367         hasVpnBusiness(subscription)
368     );
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([
384     VPN_PRO,
385     VPN_BUSINESS,
386     DRIVE_PRO,
387     DRIVE_BUSINESS,
388     PASS_PRO,
389     PASS_BUSINESS,
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) => {
404     if (!subscription) {
405         return;
406     }
408     return getPlan(subscription);
411 export const getBaseAmount = (
412     name: PLANS | ADDON_NAMES,
413     plansMap: PlansMap,
414     subscription: Subscription | undefined,
415     cycle = CYCLE.MONTHLY
416 ) => {
417     const base = plansMap[name];
418     if (!base) {
419         return 0;
420     }
421     return (subscription?.Plans || [])
422         .filter(({ Name }) => Name === name)
423         .reduce((acc) => {
424             const pricePerCycle = base.Pricing[cycle] || 0;
425             return acc + pricePerCycle;
426         }, 0);
429 export const getPlanIDs = (subscription: MaybeFreeSubscription | null): PlanIDs => {
430     return (subscription?.Plans || []).reduce<PlanIDs>((acc, { Name, Quantity }) => {
431         acc[Name] = (acc[Name] || 0) + Quantity;
432         return acc;
433     }, {});
436 export const isTrial = (subscription: Subscription | FreeSubscription | undefined, plan?: PLANS): boolean => {
437     if (isFreeSubscription(subscription)) {
438         return false;
439     }
441     const isTrialV4 =
442         subscription?.CouponCode === COUPON_CODES.REFERRAL ||
443         subscription?.CouponCode === COUPON_CODES.MEMBER_DOWNGRADE_TRIAL;
444     const isTrialV5 = !!subscription?.IsTrial;
445     const trial = isTrialV4 || isTrialV5;
447     if (!plan) {
448         return trial;
449     }
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
467 ) => {
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 => {
478     if (!coupon) {
479         return false;
480     }
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 => {
492     if (!coupon) {
493         return false;
494     }
495     return blackFriday2024Discounts.has(coupon?.toUpperCase());
498 export const allCycles = Object.freeze(
499     Object.values(CYCLE)
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) => {
516     if (!subscription) {
517         return false;
518     }
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 {
526     if (!cycle) {
527         return undefined;
528     }
530     if (regularCycles.includes(cycle)) {
531         return cycle;
532     }
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) {
539             return regularCycle;
540         }
541     }
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 {
549     if (!cycle) {
550         return undefined;
551     }
552     if (cycle === CYCLE.MONTHLY) {
553         return CYCLE.YEARLY;
554     }
555     if (cycle === CYCLE.YEARLY) {
556         return CYCLE.TWO_YEARS;
557     }
559     if (cycle === CYCLE.FIFTEEN || cycle === CYCLE.THIRTY) {
560         return CYCLE.TWO_YEARS;
561     }
563     return cycle;
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;
600     /**
601      * That's pricing that counts only aggregate of cost for members. That's useful for rendering of
602      * "per user per month" pricing.
603      * Examples:
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`.
609      *
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.
615      */
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;
635     }
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];
643         if (!plan) {
644             return acc;
645         }
647         return acc + getPlanMembers(plan, quantity, view);
648     }, 0);
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];
660     }
662     if (cycle === CYCLE.YEARLY) {
663         return INCLUDED_IP_PRICING[CYCLE.YEARLY];
664     }
666     if (cycle === CYCLE.TWO_YEARS) {
667         return INCLUDED_IP_PRICING[CYCLE.TWO_YEARS];
668     }
670     return 0;
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.
680  */
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);
693     }
695     if (isMultiUserPersonalPlan(plan)) {
696         return totalPrice;
697     }
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) {
711             delete acc[cycle];
712         }
714         return acc;
715     }, {} as Pricing);
718 interface OfferResult {
719     pricing: Pricing;
720     cycles: CYCLE[];
721     valid: boolean;
724 export const getPlanOffer = (plan: Plan) => {
725     const result = [CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS].reduce<OfferResult>(
726         (acc, cycle) => {
727             acc.pricing[cycle] = (plan.DefaultPricing?.[cycle] ?? 0) - (getPricePerCycle(plan, cycle) ?? 0);
728             return acc;
729         },
730         {
731             valid: false,
732             cycles: [],
733             pricing: {
734                 [CYCLE.MONTHLY]: 0,
735                 [CYCLE.YEARLY]: 0,
736                 [CYCLE.THREE]: 0,
737                 [CYCLE.TWO_YEARS]: 0,
738                 [CYCLE.FIFTEEN]: 0,
739                 [CYCLE.EIGHTEEN]: 0,
740                 [CYCLE.THIRTY]: 0,
741             },
742         }
743     );
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) {
747         result.valid = true;
748     }
749     return result;
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,
756 } as const;
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.
762  */
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
767     if (!planName) {
768         return 0;
769     }
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),
777         includedIPs
778     );
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);
788     if (isVpnB2BPlan) {
789         return true;
790     }
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) =>
795             isIpAddon(plan)
796         );
797         return hasIpAddons;
798     }
800     return false;
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.
808  */
809 export const hasCancellablePlan = (subscription: Subscription | undefined, user: UserModel) => {
810     if (isCancellableOnlyViaSupport(subscription)) {
811         return false;
812     }
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
822     // simpler to handle
823     const splittedUser = isSplittedUser(user.ChargebeeUser, user.ChargebeeUserExists, subscription?.BillingPlatform);
825     return cancellablePlan || chargebeeForced || splittedUser;
828 export function hasMaximumCycle(subscription?: SubscriptionModel | FreeSubscription): boolean {
829     return (
830         subscription?.Cycle === CYCLE.TWO_YEARS ||
831         subscription?.Cycle === CYCLE.THIRTY ||
832         subscription?.UpcomingSubscription?.Cycle === CYCLE.TWO_YEARS ||
833         subscription?.UpcomingSubscription?.Cycle === CYCLE.THIRTY
834     );
837 export const getMaximumCycleForApp = (app: ProductParam, currency?: Currency) => {
838     if (app === APPS.PROTONPASS || app === APPS.PROTONWALLET) {
839         return CYCLE.YEARLY;
840     }
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;
844     }
846     return currency && isRegionalCurrency(currency) ? CYCLE.YEARLY : CYCLE.TWO_YEARS;
849 export const getPlanMaxIPs = (plan: Plan) => {
850     if (plan.Name === PLANS.VPN_BUSINESS) {
851         return 1;
852     }
854     if (isIpAddon(plan.Name)) {
855         return 1;
856     }
858     return 0;
861 const getPlanMaxAIs = (plan: Plan) => {
862     return isScribeAddon(plan.Name) ? 1 : 0;
865 export const getMaxValue = (plan: Plan, key: MaxKeys): number => {
866     let result: number;
868     if (key === 'MaxIPs') {
869         result = getPlanMaxIPs(plan);
870     } else if (key === 'MaxAI') {
871         result = getPlanMaxAIs(plan);
872     } else {
873         result = plan[key];
874     }
876     return result ?? 0;
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) {
884             addonMultiplier = 1;
885         }
886     } else {
887         addonMultiplier = getMaxValue(addon, addonMaxKey);
888     }
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)) {
901         return false;
902     }
904     const plan = getPlan(subscription);
906     const isLaunchOffer = plan?.Offer === PASS_LAUNCH_OFFER;
908     return isLaunchOffer && subscription.Cycle === CYCLE.YEARLY && plan?.Name === PLANS.PASS;