diff --git a/src/views/Checkout/components/CheckoutLoader.tsx b/src/views/Checkout/components/CheckoutLoader.tsx new file mode 100644 index 0000000000..ea228b8ccf --- /dev/null +++ b/src/views/Checkout/components/CheckoutLoader.tsx @@ -0,0 +1,7 @@ +import { Loader } from '@internxt/ui'; + +export const CheckoutLoader = () => ( +
+ +
+); diff --git a/src/views/Checkout/services/payment.service.test.ts b/src/views/Checkout/services/payment.service.test.ts index 8ba25cfe45..569c4351df 100644 --- a/src/views/Checkout/services/payment.service.test.ts +++ b/src/views/Checkout/services/payment.service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, test } from 'vitest'; import { loadStripe } from '@stripe/stripe-js/pure'; import paymentService from './payment.service'; import { SdkFactory } from '../../../app/core/factory/sdk'; @@ -34,6 +34,7 @@ describe('paymentService', () => { const mockStripe = { redirectToCheckout: vi.fn(), + confirmCardPayment: vi.fn(), }; beforeEach(() => { @@ -141,6 +142,65 @@ describe('paymentService', () => { }); }); + describe('Update subscription with confirmation', () => { + test('When the user updates the subscription with confirmation, the 3DS is requested and checked', async () => { + const updateSubscriptionPricePayload = { + userSubscription: { id: 'sub_123' }, + request3DSecure: true, + clientSecret: 'cs_123', + }; + + mockPaymentsClient.updateSubscriptionPrice.mockResolvedValue(updateSubscriptionPricePayload); + mockStripe.confirmCardPayment.mockResolvedValue({ paymentIntent: { status: 'succeeded' } }); + const onSuccessCallback = vi.fn(); + + await paymentService.updateSubscriptionWithConfirmation({ + priceId: 'price_123', + userType: UserType.Individual, + coupon: 'DISCOUNT', + onSuccess: onSuccessCallback, + onError: vi.fn(), + }); + + expect(mockPaymentsClient.updateSubscriptionPrice).toHaveBeenCalledWith({ + priceId: 'price_123', + couponCode: 'DISCOUNT', + userType: UserType.Individual, + }); + expect(mockStripe.confirmCardPayment).toHaveBeenCalledWith(updateSubscriptionPricePayload.clientSecret); + expect(onSuccessCallback).toHaveBeenCalled(); + }); + + test('When the user updates the subscription and there is an error with the confirmation, then the error is handled correctly', async () => { + const updateSubscriptionPricePayload = { + userSubscription: { id: 'sub_123' }, + request3DSecure: true, + clientSecret: 'cs_123', + }; + const mockedMessageError = 'Error updating price'; + + mockPaymentsClient.updateSubscriptionPrice.mockResolvedValue(updateSubscriptionPricePayload); + mockStripe.confirmCardPayment.mockResolvedValue({ error: { message: mockedMessageError } }); + const onErrorCallback = vi.fn(); + + await paymentService.updateSubscriptionWithConfirmation({ + priceId: 'price_123', + userType: UserType.Individual, + coupon: 'DISCOUNT', + onSuccess: vi.fn(), + onError: onErrorCallback, + }); + + expect(mockPaymentsClient.updateSubscriptionPrice).toHaveBeenCalledWith({ + priceId: 'price_123', + couponCode: 'DISCOUNT', + userType: UserType.Individual, + }); + expect(mockStripe.confirmCardPayment).toHaveBeenCalledWith(updateSubscriptionPricePayload.clientSecret); + expect(onErrorCallback).toHaveBeenCalledWith(new Error(mockedMessageError)); + }); + }); + describe('cancelSubscription', () => { it('stops ongoing subscription for user', async () => { mockPaymentsClient.cancelSubscription.mockResolvedValue(undefined); diff --git a/src/views/Checkout/services/payment.service.ts b/src/views/Checkout/services/payment.service.ts index 62e48a9553..90439531b4 100644 --- a/src/views/Checkout/services/payment.service.ts +++ b/src/views/Checkout/services/payment.service.ts @@ -177,6 +177,34 @@ const paymentService = { return paymentsClient.updateSubscriptionPrice({ priceId, couponCode: coupon, userType }); }, + async updateSubscriptionWithConfirmation({ + priceId, + userType, + coupon, + onSuccess, + onError, + }: { + priceId: string; + userType: UserType.Individual | UserType.Business; + coupon?: string; + onSuccess: () => void; + onError: (error: Error) => void; + }): Promise { + const stripe = await this.getStripe(); + const updatedSubscription = await this.updateSubscriptionPrice({ priceId, coupon, userType }); + + if (updatedSubscription.request3DSecure) { + const result = await stripe.confirmCardPayment(updatedSubscription.clientSecret); + if (result?.error?.message) { + onError(new Error(result.error.message)); + } else { + onSuccess(); + } + } else { + onSuccess(); + } + }, + async updateWorkspaceMembers(workspaceId: string, subscriptionId: string, updatedMembers: number) { const paymentsClient = await SdkFactory.getNewApiInstance().createPaymentsClient(); return paymentsClient.updateWorkspaceMembers(workspaceId, subscriptionId, updatedMembers); diff --git a/src/views/Checkout/views/CheckoutViewWrapper.tsx b/src/views/Checkout/views/CheckoutViewWrapper.tsx index 9311178b14..0d8d25c199 100644 --- a/src/views/Checkout/views/CheckoutViewWrapper.tsx +++ b/src/views/Checkout/views/CheckoutViewWrapper.tsx @@ -4,7 +4,6 @@ import { Stripe, StripeElements } from '@stripe/stripe-js'; import { BaseSyntheticEvent, useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Loader } from '@internxt/ui'; import { useCheckout } from 'views/Checkout/hooks/useCheckout'; import { useSignUp } from 'views/Signup/hooks/useSignup'; import envService from 'services/env.service'; @@ -44,13 +43,14 @@ import { usePromotionalCode } from '../hooks/usePromotionalCode'; import { useAuthCheckout } from '../hooks/useAuthCheckout'; import { checkoutReducer, initialStateForCheckout } from '../store'; import { processPcCloudPayment } from '../utils/pcCloud.utils'; +import { CheckoutLoader } from '../components/CheckoutLoader'; const CheckoutViewWrapper = () => { const { translate } = useTranslationContext(); const { checkoutTheme } = useThemeContext(); const user = useSelector((state) => state.user.user); const [state, dispatchReducer] = useReducer(checkoutReducer, initialStateForCheckout); - const { authMethod, isPaying, isUpdateSubscriptionDialogOpen, isUpdatingSubscription, prices } = state; + const { authMethod, isPaying, isUpdateSubscriptionDialogOpen, isUpdatingSubscription } = state; const { setAuthMethod, setIsUserPaying, @@ -107,7 +107,7 @@ const CheckoutViewWrapper = () => { const renewsAtPCComp = `${translate('checkout.productCard.pcMobileRenews')}`; - const canChangePlanDialogBeOpened = prices && selectedPlan?.price && isUpdateSubscriptionDialogOpen; + const canChangePlanDialogBeOpened = selectedPlan?.price && isUpdateSubscriptionDialogOpen; const isCryptoPaymentDialogOpen = isDialogOpen(CRYPTO_PAYMENT_DIALOG_KEY); const userInfo: UserInfoProps = { @@ -125,7 +125,7 @@ const CheckoutViewWrapper = () => { document.cookie = `gclid=${gclid}; expires=${expiryDate.toUTCString()}; path=/`; localStorageService.set(STORAGE_KEYS.GCLID, gclid); } - }, [checkoutTheme]); + }, []); useEffect(() => { if (isAuthenticated && user) { @@ -237,30 +237,13 @@ const CheckoutViewWrapper = () => { } try { - const updatedSubscription = await paymentService.updateSubscriptionPrice({ - priceId, + await paymentService.updateSubscriptionWithConfirmation({ + priceId: selectedPlan.price.id, userType: selectedPlan.price.type, + coupon: promotionCode ?? undefined, + onSuccess: handlePaymentSuccess, + onError: (error) => handleErrorMessage(error, translate('notificationMessages.errorCancelSubscription')), }); - if (updatedSubscription.request3DSecure && stripeSdk) { - stripeSdk - .confirmCardPayment(updatedSubscription.clientSecret) - .then(async (result) => { - if (result.error) { - notificationsService.show({ - type: ToastType.Error, - text: result.error.message as string, - }); - } else { - handlePaymentSuccess(); - } - }) - .catch((err) => { - const error = errorService.castError(err); - handleErrorMessage(error, translate('notificationMessages.errorCancelSubscription')); - }); - } else { - handlePaymentSuccess(); - } } catch (err) { const error = errorService.castError(err); handleErrorMessage(error, translate('notificationMessages.errorCancelSubscription')); @@ -287,7 +270,7 @@ const CheckoutViewWrapper = () => { const isStripeNotLoaded = !stripeSDK || !elements; const customerName = companyName ?? userName; - const authCaptcha = await generateCaptchaToken(); + const captchaToken = await generateCaptchaToken(); if (authMethod !== 'userIsSignedIn') { await onAuthenticateUser({ @@ -295,7 +278,7 @@ const CheckoutViewWrapper = () => { password, authMethod, dispatch, - authCaptcha, + authCaptcha: captchaToken, doRegister, onAuthenticationFail: () => { userAuthComponentRef.current?.scrollIntoView(); @@ -344,8 +327,6 @@ const CheckoutViewWrapper = () => { elements, }); } else { - const intentCaptcha = await generateCaptchaToken(); - await handleUserPayment({ confirmPayment: stripeSDK.confirmPayment, confirmSetupIntent: stripeSDK.confirmSetup, @@ -358,7 +339,7 @@ const CheckoutViewWrapper = () => { selectedPlan, token, gclidStored, - captchaToken: intentCaptcha, + captchaToken, seatsForBusinessSubscription: businessSeats, openCryptoPaymentDialog, userAddress: userLocationData?.ip as string, @@ -451,11 +432,17 @@ const CheckoutViewWrapper = () => { /> {canChangePlanDialogBeOpened ? ( @@ -468,9 +455,7 @@ const CheckoutViewWrapper = () => { )} ) : ( -
- -
+ )} ); diff --git a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx index ff9f18d67b..d3f2aa7726 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/PlansSection.tsx @@ -169,9 +169,9 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) }, []); const showCancelSubscriptionErrorNotification = useCallback( - (errorMessage?: string) => + (error?: Error) => notificationsService.show({ - text: errorMessage ?? translate('notificationMessages.errorCancelSubscription'), + text: error?.message ?? translate('notificationMessages.errorCancelSubscription'), type: ToastType.Error, }), [translate], @@ -189,37 +189,16 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps) const handleSubscriptionPayment = async (priceId: string) => { try { - stripe = await getStripe(stripe); - const updatedSubscription = await paymentService.updateSubscriptionPrice({ - priceId, + await paymentService.updateSubscriptionWithConfirmation({ + priceId: priceId, userType: selectedSubscriptionType, + onSuccess: handlePaymentSuccess, + onError: showCancelSubscriptionErrorNotification, }); - - if (updatedSubscription.request3DSecure) { - stripe - .confirmCardPayment(updatedSubscription.clientSecret) - .then(async (result) => { - if (result.error) { - notificationsService.show({ - type: ToastType.Error, - text: result.error.message as string, - }); - } else { - handlePaymentSuccess(); - } - }) - .catch((err) => { - const error = errorService.castError(err); - errorService.reportError(error); - showCancelSubscriptionErrorNotification(error.message); - }); - } else { - handlePaymentSuccess(); - } } catch (err) { const error = errorService.castError(err); errorService.reportError(error); - showCancelSubscriptionErrorNotification(error.message); + showCancelSubscriptionErrorNotification(error); } }; @@ -333,12 +312,11 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps)
{shouldDisplayChangePlanDialog() && priceSelected && ( diff --git a/src/views/NewSettings/components/Sections/Account/Plans/components/ChangePlanDialog.tsx b/src/views/NewSettings/components/Sections/Account/Plans/components/ChangePlanDialog.tsx index 69fff9602f..8ed7889c14 100644 --- a/src/views/NewSettings/components/Sections/Account/Plans/components/ChangePlanDialog.tsx +++ b/src/views/NewSettings/components/Sections/Account/Plans/components/ChangePlanDialog.tsx @@ -9,21 +9,19 @@ import { RootState } from 'app/store'; import { PlanState } from 'app/store/slices/plan'; const ChangePlanDialog = ({ - prices, isDialogOpen, setIsDialogOpen, onPlanClick, isUpdatingSubscription, - priceIdSelected, + priceSelected, subscriptionSelected, isLoading, }: { - prices: DisplayPrice[]; isDialogOpen: boolean; isUpdatingSubscription?: boolean; setIsDialogOpen: (value: boolean) => void; onPlanClick: (value: string, currency: string) => void; - priceIdSelected: string; + priceSelected: DisplayPrice; subscriptionSelected: UserType; isLoading?: boolean; }): JSX.Element => { @@ -43,11 +41,10 @@ const ChangePlanDialog = ({ const subscription = isIndividualSubscription ? individualSubscription : businessSubscription; const userHasLifetimeSub = individualSubscription?.type === 'lifetime'; - const selectedPlan: DisplayPrice = prices.find((price) => price.id === priceIdSelected) as DisplayPrice; - const selectedPlanSize = selectedPlan?.bytes; - const selectedPlanAmount = selectedPlan?.amount; - const selectedPlanInterval = selectedPlan?.interval; + const selectedPlanSize = priceSelected?.bytes; + const selectedPlanAmount = priceSelected?.amount; + const selectedPlanInterval = priceSelected?.interval; const selectedPlanSizeString = userHasLifetimeSub && selectedPlanInterval === 'lifetime' ? bytesToString(selectedPlanSize + planLimit) @@ -158,7 +155,7 @@ const ChangePlanDialog = ({