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 = ({