Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/views/Checkout/components/CheckoutLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Loader } from '@internxt/ui';

export const CheckoutLoader = () => (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a component, remember add it to corresponding folder

<div className="flex h-full items-center justify-center bg-gray-1">
<Loader type="pulse" />
</div>
);
62 changes: 61 additions & 1 deletion src/views/Checkout/services/payment.service.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,6 +34,7 @@ describe('paymentService', () => {

const mockStripe = {
redirectToCheckout: vi.fn(),
confirmCardPayment: vi.fn(),
};

beforeEach(() => {
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions src/views/Checkout/services/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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);
Expand Down
57 changes: 21 additions & 36 deletions src/views/Checkout/views/CheckoutViewWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<RootState, UserSettings | undefined>((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,
Expand Down Expand Up @@ -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 = {
Expand All @@ -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) {
Expand Down Expand Up @@ -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'));
Expand All @@ -287,15 +270,15 @@ const CheckoutViewWrapper = () => {
const isStripeNotLoaded = !stripeSDK || !elements;
const customerName = companyName ?? userName;

const authCaptcha = await generateCaptchaToken();
const captchaToken = await generateCaptchaToken();

if (authMethod !== 'userIsSignedIn') {
await onAuthenticateUser({
email,
password,
authMethod,
dispatch,
authCaptcha,
authCaptcha: captchaToken,
doRegister,
onAuthenticationFail: () => {
userAuthComponentRef.current?.scrollIntoView();
Expand Down Expand Up @@ -344,8 +327,6 @@ const CheckoutViewWrapper = () => {
elements,
});
} else {
const intentCaptcha = await generateCaptchaToken();

await handleUserPayment({
confirmPayment: stripeSDK.confirmPayment,
confirmSetupIntent: stripeSDK.confirmSetup,
Expand All @@ -358,7 +339,7 @@ const CheckoutViewWrapper = () => {
selectedPlan,
token,
gclidStored,
captchaToken: intentCaptcha,
captchaToken,
seatsForBusinessSubscription: businessSeats,
openCryptoPaymentDialog,
userAddress: userLocationData?.ip as string,
Expand Down Expand Up @@ -451,11 +432,17 @@ const CheckoutViewWrapper = () => {
/>
{canChangePlanDialogBeOpened ? (
<ChangePlanDialog
prices={prices}
isDialogOpen={isUpdateSubscriptionDialogOpen}
setIsDialogOpen={setIsUpdateSubscriptionDialogOpen}
onPlanClick={onChangePlanClicked}
priceIdSelected={selectedPlan.price.id}
priceSelected={{
amount: selectedPlan.price.amount,
currency: selectedPlan.price.currency,
interval: selectedPlan.price.interval,
userType: selectedPlan.price.type,
bytes: selectedPlan.price.bytes,
id: selectedPlan.price.id,
}}
isUpdatingSubscription={isUpdatingSubscription}
subscriptionSelected={selectedPlan.price.type}
/>
Expand All @@ -468,9 +455,7 @@ const CheckoutViewWrapper = () => {
)}
</Elements>
) : (
<div className="flex h-full items-center justify-center bg-gray-1">
<Loader type="pulse" />
</div>
<CheckoutLoader />
)}
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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);
}
};

Expand Down Expand Up @@ -333,12 +312,11 @@ const PlansSection = ({ changeSection, onClosePreferences }: PlansSectionProps)
<Section title="Plans" onClosePreferences={onClosePreferences}>
{shouldDisplayChangePlanDialog() && priceSelected && (
<ChangePlanDialog
prices={isIndividualSubscriptionSelected ? individualPrices : businessPrices}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
onPlanClick={onChangePlanClicked}
isUpdatingSubscription={isUpdatingSubscription}
priceIdSelected={priceSelected.id}
priceSelected={priceSelected}
subscriptionSelected={selectedSubscriptionType}
isLoading={isLoadingCheckout}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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)
Expand Down Expand Up @@ -158,7 +155,7 @@ const ChangePlanDialog = ({
</Button>
<Button
variant="primary"
onClick={() => onPlanClick(priceIdSelected, selectedPlan?.currency)}
onClick={() => onPlanClick(priceSelected.id, priceSelected?.currency)}
loading={isLoading}
disabled={isUpdatingSubscription}
>
Expand Down
Loading