1 import { c } from 'ttag';
3 import { getAutoCoupon } from '@proton/components/containers/payments/subscription/helpers';
4 import { getMaybeForcePaymentsVersion } from '@proton/components/payments/client-extensions';
5 import type { BillingAddress, PAYMENT_METHOD_TYPES, PaymentsApi, SavedPaymentMethod } from '@proton/payments';
14 } from '@proton/payments';
15 import { getOrganization } from '@proton/shared/lib/api/organization';
16 import { getSubscription, queryPaymentMethods } from '@proton/shared/lib/api/payments';
17 import type { APP_NAMES } from '@proton/shared/lib/constants';
18 import { APPS, COUPON_CODES, CYCLE } from '@proton/shared/lib/constants';
19 import { getOptimisticCheckResult } from '@proton/shared/lib/helpers/checkout';
20 import isDeepEqual from '@proton/shared/lib/helpers/isDeepEqual';
23 getPricingFromPlanIDs,
25 isLifetimePlanSelected,
27 } from '@proton/shared/lib/helpers/planIDs';
29 getHas2024OfferCoupon,
30 getIsB2BAudienceFromPlan,
31 getNormalCycleFromCustomCycle,
34 } from '@proton/shared/lib/helpers/subscription';
44 SubscriptionCheckResponse,
47 } from '@proton/shared/lib/interfaces';
48 import { Audience } from '@proton/shared/lib/interfaces';
49 import { FREE_PLAN, getFreeCheckResult } from '@proton/shared/lib/subscription/freePlans';
52 isAdmin as getIsAdmin,
56 } from '@proton/shared/lib/user/helpers';
58 import { getSubscriptionPrices } from '../signup/helper';
59 import type { SessionData, SignupCacheResult, SubscriptionData } from '../signup/interfaces';
60 import type { PlanCard } from './PlanCardSelector';
61 import type { Options, PlanParameters, SignupConfiguration, SignupParameters2, Upsell } from './interface';
62 import { UpsellTypes } from './interface';
64 export const getFreeTitle = (appName: string) => {
65 return c('Title').t`${appName} Free`;
68 export const getIsProductB2BPlan = (plan: PLANS | ADDON_NAMES | undefined) => {
79 return proPlans.some((proPlan) => plan === proPlan);
82 export const getIsBundleB2BPlan = (plan: PLANS | ADDON_NAMES | undefined) => {
83 return [PLANS.BUNDLE_PRO, PLANS.BUNDLE_PRO_2024].some((bundlePlan) => plan === bundlePlan);
86 export const getHasAnyPlusPlan = (subscribedPlan: PLANS | ADDON_NAMES | undefined) => {
87 return [PLANS.MAIL, PLANS.DRIVE, PLANS.VPN, PLANS.VPN2024, PLANS.PASS, PLANS.VPN_PASS_BUNDLE].some(
88 (plan) => plan === subscribedPlan
92 export const getFreeSubscriptionData = (
93 subscriptionData: Omit<SubscriptionData, 'checkResult' | 'planIDs' | 'payment'>
94 ): SubscriptionData => {
97 checkResult: getFreeCheckResult(
98 subscriptionData.currency,
99 // "Reset" the cycle because the custom cycles are only valid with a coupon
100 getNormalCycleFromCustomCycle(subscriptionData.cycle)
107 export const getSubscriptionData = async (
108 paymentsApi: PaymentsApi,
112 ): Promise<SubscriptionData> => {
113 const { planIDs, checkResult } = await getSubscriptionPrices(
115 options.planIDs || {},
118 options.billingAddress,
121 .then((checkResult) => {
124 planIDs: options.planIDs,
128 if (!options?.info) {
130 checkResult: getFreeCheckResult(
132 // "Reset" the cycle because the custom cycles are only valid with a coupon
133 getNormalCycleFromCustomCycle(options.cycle)
138 // If this is only an "informational" call, like what we would display in a plan/cycle card at signup, we can calculate the price optimistically
141 ...getOptimisticCheckResult(options),
142 Currency: options.currency,
145 planIDs: options.planIDs,
149 cycle: checkResult.Cycle,
150 currency: checkResult.Currency,
152 planIDs: planIDs || {},
153 skipUpsell: options.skipUpsell ?? false,
154 billingAddress: options.billingAddress,
158 const hasSelectedPlan = (plan: Plan | undefined, plans: (PLANS | ADDON_NAMES)[]): plan is Plan => {
159 return plans.some((planName) => plan?.Name === planName);
162 const getUnlockPlanName = (toApp: APP_NAMES) => {
163 if (toApp === APPS.PROTONPASS) {
166 if (toApp === APPS.PROTONMAIL || toApp === APPS.PROTONCALENDAR) {
169 if (toApp === APPS.PROTONDRIVE) {
175 const getSafePlan = (plansMap: PlansMap, planName: PLANS | ADDON_NAMES) => {
176 const plan = plansMap[planName];
178 throw new Error('Missing plan');
193 currentPlan?: SubscriptionPlan | undefined;
194 upsellPlanCard?: PlanCard;
195 subscription?: Subscription;
198 planParameters: PlanParameters;
201 const hasMonthlyCycle = subscription?.Cycle === CYCLE.MONTHLY;
205 unlockPlan: plansMap[getUnlockPlanName(toApp)],
207 mode: UpsellTypes.PLANS,
208 subscriptionOptions: {},
212 if (toApp === APPS.PROTONWALLET) {
216 const getBlackFridayUpsellData = ({
218 cycle = CYCLE.YEARLY,
219 coupon = COUPON_CODES.BLACK_FRIDAY_2024,
228 subscriptionOptions: {
235 mode: UpsellTypes.UPSELL,
239 const getUpsellData = (plan: PLANS | ADDON_NAMES) => {
242 plan: plansMap[plan],
243 mode: UpsellTypes.UPSELL,
247 if (currentPlan && planParameters.defined) {
248 if (planParameters.plan.Name === PLANS.VISIONARY) {
249 return getUpsellData(planParameters.plan.Name);
252 if (isLifetimePlanSelected(options.planIDs ?? {}) && !getIsB2BAudienceFromPlan(currentPlan.Name)) {
253 return getBlackFridayUpsellData({ plan: getSafePlan(plansMap, PLANS.PASS_LIFETIME) });
256 if (getHas2024OfferCoupon(options.coupon)) {
257 if (getHasAnyPlusPlan(currentPlan.Name)) {
258 if (currentPlan.Name === PLANS.PASS) {
260 options.cycle === CYCLE.YEARLY &&
261 hasSelectedPlan(planParameters.plan, [PLANS.PASS_FAMILY, PLANS.BUNDLE, PLANS.DUO, PLANS.FAMILY])
263 return getBlackFridayUpsellData({ plan: planParameters.plan });
265 const plan = getSafePlan(plansMap, PLANS.PASS_FAMILY);
266 return getBlackFridayUpsellData({ plan });
269 if ((currentPlan.Name === PLANS.VPN2024 || currentPlan.Name === PLANS.VPN) && hasMonthlyCycle) {
271 (options.cycle === CYCLE.YEARLY || options.cycle === CYCLE.TWO_YEARS) &&
272 hasSelectedPlan(planParameters.plan, [PLANS.VPN2024])
274 return getBlackFridayUpsellData({
275 plan: planParameters.plan,
276 cycle: options.cycle,
277 coupon: options.coupon,
282 const isValidBundleDuoFamilyFromPlus =
283 options.cycle === CYCLE.YEARLY &&
284 hasSelectedPlan(planParameters.plan, [PLANS.BUNDLE, PLANS.DUO, PLANS.FAMILY]);
286 if (isValidBundleDuoFamilyFromPlus) {
287 return getBlackFridayUpsellData({ plan: planParameters.plan });
289 // Any other selected plan will give yearly bundle
290 return getBlackFridayUpsellData({ plan: getSafePlan(plansMap, PLANS.BUNDLE) });
293 if (currentPlan.Name === PLANS.BUNDLE) {
295 subscription?.CouponCode === COUPON_CODES.DEGOOGLE &&
297 options.cycle === CYCLE.YEARLY &&
298 hasSelectedPlan(planParameters.plan, [PLANS.BUNDLE])
300 return getBlackFridayUpsellData({ plan: planParameters.plan });
302 if (options.cycle === CYCLE.YEARLY && hasSelectedPlan(planParameters.plan, [PLANS.DUO, PLANS.FAMILY])) {
303 return getBlackFridayUpsellData({ plan: planParameters.plan });
305 return getBlackFridayUpsellData({ plan: getSafePlan(plansMap, PLANS.DUO) });
308 if (currentPlan.Name === PLANS.PASS_FAMILY || currentPlan.Name === PLANS.DUO) {
309 if (options.cycle === CYCLE.YEARLY && hasSelectedPlan(planParameters.plan, [PLANS.FAMILY])) {
310 return getBlackFridayUpsellData({ plan: planParameters.plan });
312 return getBlackFridayUpsellData({ plan: getSafePlan(plansMap, PLANS.FAMILY) });
315 if (getIsProductB2BPlan(currentPlan.Name) && !getIsBundleB2BPlan(planParameters.plan.Name)) {
316 return getUpsellData(PLANS.BUNDLE_PRO_2024);
319 if (audience === Audience.B2B) {
320 if (getIsProductB2BPlan(planParameters.plan.Name) || getIsBundleB2BPlan(planParameters.plan.Name)) {
321 return getUpsellData(planParameters.plan.Name);
325 if (getHasAnyPlusPlan(currentPlan.Name)) {
327 hasSelectedPlan(planParameters.plan, [PLANS.PASS_FAMILY, PLANS.BUNDLE, PLANS.DUO, PLANS.FAMILY])
329 return getUpsellData(planParameters.plan.Name);
331 return getUpsellData(PLANS.BUNDLE);
334 if (currentPlan.Name === PLANS.BUNDLE) {
335 if (hasSelectedPlan(planParameters.plan, [PLANS.DUO, PLANS.FAMILY])) {
336 return getUpsellData(planParameters.plan.Name);
338 return getUpsellData(PLANS.DUO);
341 if (currentPlan.Name === PLANS.PASS_FAMILY || currentPlan.Name === PLANS.DUO) {
342 if (hasSelectedPlan(planParameters.plan, [PLANS.DUO, PLANS.FAMILY])) {
343 return getUpsellData(planParameters.plan.Name);
345 return getUpsellData(PLANS.FAMILY);
348 if (getIsProductB2BPlan(currentPlan.Name) && !getIsBundleB2BPlan(planParameters.plan.Name)) {
349 return getUpsellData(PLANS.BUNDLE_PRO_2024);
358 export const getRelativeUpsellPrice = (
361 checkResult: SubscriptionCheckResponse | undefined,
362 subscription: Subscription | undefined,
365 if (!upsell.currentPlan || !upsell.plan) {
369 if (subscription && checkResult) {
371 (checkResult.Amount + (checkResult?.CouponDiscount || 0)) / cycle - subscription.Amount / subscription.Cycle
375 const pricingCurrentPlan = getPricingFromPlanIDs({ [upsell.currentPlan.Name]: 1 }, plansMap);
376 const pricingUpsell = getPricingFromPlanIDs({ [upsell.plan.Name]: 1 }, plansMap);
378 return pricingUpsell.plans[cycle] / cycle - pricingCurrentPlan.plans[cycle] / cycle;
390 currentPlan?: SubscriptionPlan;
392 if (toApp === APPS.PROTONPASS) {
393 if (audience === Audience.B2B) {
399 PLANS.BUNDLE_PRO_2024,
401 ].includes(currentPlan?.Name as any);
403 return hasPaidPass(user);
406 if ([APPS.PROTONMAIL, APPS.PROTONCALENDAR].includes(toApp as any)) {
407 return hasPaidMail(user);
410 if (toApp === APPS.PROTONDRIVE) {
411 return hasPaidDrive(user);
417 export const getUserInfo = async ({
431 paymentsApi: PaymentsApi;
432 user?: User | undefined;
437 upsellPlanCard?: PlanCard;
438 planParameters: PlanParameters;
439 signupParameters: SignupParameters2;
442 paymentMethods: SavedPaymentMethod[];
443 subscription: Subscription | undefined;
444 subscriptionData: SubscriptionData;
445 organization: Organization | undefined;
446 state: SessionData['state'];
448 defaultPaymentMethod: PAYMENT_METHOD_TYPES | undefined;
453 subscription: undefined,
454 subscriptionData: await getSubscriptionData(paymentsApi, options),
455 organization: undefined,
456 defaultPaymentMethod: undefined,
463 upsell: getUpsell({ audience, plansMap, upsellPlanCard, options, planParameters, toApp }),
468 payable: getCanPay(user),
469 admin: getIsAdmin(user),
470 subscribed: Boolean(user.Subscribed),
474 const forcePaymentsVersion = getMaybeForcePaymentsVersion(user);
476 const [paymentMethods, subscription, organization] = await Promise.all([
478 ? api(queryPaymentMethods(forcePaymentsVersion)).then(({ PaymentMethods }) => PaymentMethods)
480 state.payable && state.admin && state.subscribed
481 ? api(getSubscription(forcePaymentsVersion)).then(
482 ({ Subscription, UpcomingSubscription }) => UpcomingSubscription ?? Subscription
484 : (FREE_SUBSCRIPTION as unknown as Subscription),
487 Organization: Organization;
488 }>(getOrganization()).then(({ Organization }) => Organization)
492 const currentPlan = (() => {
493 const plan = getPlan(subscription);
498 return plansMap[organization.PlanName];
502 const upsell = getUpsell({
513 if (user && hasAccess({ toApp, user, audience, currentPlan })) {
518 if (toApp === APPS.PROTONWALLET) {
519 state.access = false;
522 // Disable the access modal and show the upsell flow instead
523 if (state.payable && upsell.plan?.Name) {
524 state.access = false;
527 const subscriptionData = await (() => {
528 const optionsWithSubscriptionDefaults = {
530 // TODO: make this more generic
531 cycle: signupParameters.cycle || subscription.Cycle || options.cycle,
532 currency: options.currency,
533 coupon: subscription.CouponCode || options.coupon,
536 if (!state.payable || state.access) {
537 return getFreeSubscriptionData(optionsWithSubscriptionDefaults);
541 return getSubscriptionData(paymentsApi, {
542 ...optionsWithSubscriptionDefaults,
543 ...upsell.subscriptionOptions,
544 planIDs: switchPlan({
545 planIDs: getPlanIDs(subscription),
546 planID: upsell.plan.Name,
554 return getSubscriptionData(paymentsApi, optionsWithSubscriptionDefaults);
559 defaultPaymentMethod: undefined,
568 export const getSessionDataFromSignup = (cache: SignupCacheResult): SessionData => {
569 const setupData = cache.setupData;
571 throw new Error('Missing setup data');
574 UID: setupData.authResponse.UID,
575 user: setupData.user,
576 localID: setupData.authResponse.LocalID,
577 clientKey: setupData.clientKey,
578 offlineKey: setupData.offlineKey,
579 keyPassword: setupData.keyPassword,
580 persistent: cache.persistent,
581 trusted: cache.trusted,
582 subscription: undefined,
583 organization: undefined,
585 defaultPaymentMethod: undefined,
595 export const runAfterScroll = (el: Element, done: () => void) => {
597 let lastPos = el.scrollTop;
599 // Timeout after 1 second
600 const maxTime = 1000;
603 const cb = (time: number) => {
604 if (startTime === -1) {
607 if (time - startTime > maxTime) {
611 const newPos = el.scrollTop;
612 if (lastPos === newPos) {
613 if (same++ > maxFrames) {
622 requestAnimationFrame(cb);
625 requestAnimationFrame(cb);
628 export type SubscriptionDataCycleMapping = Partial<{ [key in PLANS]: CycleMapping<SubscriptionData> }>;
629 export const getPlanCardSubscriptionData = async ({
640 paymentsApi: PaymentsApi;
641 coupon?: string | null;
642 billingAddress: BillingAddress;
643 }): Promise<SubscriptionDataCycleMapping> => {
644 const result = await Promise.all(
645 planIDs.flatMap((planIDs) =>
647 .map((cycle) => [planIDs, cycle] as const)
648 .map(async ([planIDs, cycle]): Promise<SubscriptionData> => {
650 maybeCoupon === null ? undefined : getAutoCoupon({ coupon: maybeCoupon, planIDs, cycle });
652 // make sure that the plan and all its addons exist
653 const plansToCheck = Object.keys(planIDs) as (PLANS | ADDON_NAMES)[];
654 const plansExist = plansToCheck.every(
655 (planName) => plansMap[planName]?.Pricing?.[cycle] !== undefined
658 // we extract the currency of the currently selected plan in plansMap.
659 const currency = plansMap[plansToCheck[0]]?.Currency ?? DEFAULT_CURRENCY;
661 // If there's no coupon we can optimistically calculate the price.
662 // Also always exclude Enterprise (price never shown).
663 // In addition, if the selected plan doesn't exist, then we don't do the live check call.
664 if (!coupon || planIDs[PLANS.ENTERPRISE] || !plansExist) {
670 ...getOptimisticCheckResult({ planIDs, plansMap, cycle, currency }),
678 const subscriptionData = await getSubscriptionData(paymentsApi, {
687 return subscriptionData;
692 return result.reduce<SubscriptionDataCycleMapping>((acc, subscriptionData) => {
693 const plan = !hasPlanIDs(subscriptionData.planIDs)
695 : getPlanFromPlanIDs(plansMap, subscriptionData.planIDs);
699 let cycleMapping = acc[plan.Name as unknown as keyof typeof acc];
702 acc[plan.Name as unknown as keyof typeof acc] = cycleMapping;
704 cycleMapping[subscriptionData.cycle] = subscriptionData;
709 export const swapCurrency = (
710 subscriptionDataCycleMapping: SubscriptionDataCycleMapping,
712 ): SubscriptionDataCycleMapping => {
714 Object.entries(subscriptionDataCycleMapping) as [PLANS, CycleMapping<SubscriptionData>][]
715 ).reduce<SubscriptionDataCycleMapping>((acc, [planName, cycleMapping]) => {
716 acc[planName] = (Object.entries(cycleMapping) as [string, SubscriptionData][]).reduce<
717 CycleMapping<SubscriptionData>
718 >((acc, [cycle, subscriptionData]) => {
719 acc[+cycle as Cycle] = {
723 ...subscriptionData.checkResult,
733 export const getSubscriptionMapping = ({
734 subscriptionDataCycleMapping,
738 subscriptionDataCycleMapping: SubscriptionDataCycleMapping;
739 planName: PLANS | ADDON_NAMES;
743 let subscriptionMapping = subscriptionDataCycleMapping?.[planName as unknown as PLANS];
744 if (!subscriptionMapping) {
747 const firstKey = Object.keys(subscriptionMapping)[0] as unknown as keyof typeof subscriptionMapping;
748 const planIDs = subscriptionMapping[firstKey]?.planIDs;
749 if (!isDeepEqual(planIDs, newPlanIDs)) {
750 subscriptionMapping = undefined;
752 return subscriptionMapping;
755 interface GetAccessiblePlansParams {
756 planCards: SignupConfiguration['planCards'];
759 paramPlanName?: string;
762 export const getAccessiblePlans = ({
767 }: GetAccessiblePlansParams): StrictPlan[] => {
768 if (audience !== Audience.B2C && audience !== Audience.B2B) {
772 const accessiblePlanNames = planCards[audience].map(({ plan }) => plan);
773 if (paramPlanName && isStringPLAN(paramPlanName)) {
774 accessiblePlanNames.push(paramPlanName);
777 return plans.filter(({ Name }) => accessiblePlanNames.includes(Name as PLANS)) as StrictPlan[];