Skip to content

Commit

Permalink
feat(payment): add optional captcha validation to payments
Browse files Browse the repository at this point in the history
  • Loading branch information
royschut committed Nov 1, 2024
1 parent 008f1f0 commit 4c6adfc
Show file tree
Hide file tree
Showing 7 changed files with 54 additions and 12 deletions.
10 changes: 7 additions & 3 deletions packages/common/src/controllers/CheckoutController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,12 @@ export default class CheckoutController {
};

//
paymentWithoutDetails = async (): Promise<Payment> => {
paymentWithoutDetails = async ({ captchaValue }: { captchaValue?: string } = {}): Promise<Payment> => {
const { order } = useCheckoutStore.getState();

if (!order) throw new Error('No order created');

const response = await this.checkoutService.paymentWithoutDetails({ orderId: order.id });
const response = await this.checkoutService.paymentWithoutDetails({ orderId: order.id, captchaValue });

if (response.errors.length > 0) throw new Error(response.errors[0]);
if (response.responseData.rejectedReason) throw new Error(response.responseData.rejectedReason);
Expand Down Expand Up @@ -169,7 +169,7 @@ export default class CheckoutController {
return response.responseData;
};

initialAdyenPayment = async (paymentMethod: AdyenPaymentMethod, returnUrl: string): Promise<InitialAdyenPayment> => {
initialAdyenPayment = async (paymentMethod: AdyenPaymentMethod, returnUrl: string, captchaValue?: string): Promise<InitialAdyenPayment> => {
const { order } = useCheckoutStore.getState();

if (!order) throw new Error('No order created');
Expand All @@ -181,6 +181,7 @@ export default class CheckoutController {
returnUrl: returnUrl,
paymentMethod,
customerIP: await this.getCustomerIP(),
captchaValue,
});

if (response.errors.length > 0) throw new Error(response.errors[0]);
Expand Down Expand Up @@ -212,12 +213,14 @@ export default class CheckoutController {
cancelUrl,
errorUrl,
couponCode = '',
captchaValue,
}: {
successUrl: string;
waitingUrl: string;
cancelUrl: string;
errorUrl: string;
couponCode: string;
captchaValue?: string;
}): Promise<{ redirectUrl: string }> => {
const { order } = useCheckoutStore.getState();

Expand All @@ -230,6 +233,7 @@ export default class CheckoutController {
cancelUrl,
errorUrl,
couponCode,
captchaValue,
});

if (response.errors.length > 0) throw new Error(response.errors[0]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,14 @@ export default class CleengCheckoutService extends CheckoutService {
};

paymentWithPayPal: PaymentWithPayPal = async (payload) => {
const { order, successUrl, cancelUrl, errorUrl } = payload;
const { order, successUrl, cancelUrl, errorUrl, captchaValue } = payload;

const paypalPayload = {
orderId: order.id,
successUrl,
cancelUrl,
errorUrl,
captchaValue,
};

return this.cleengService.post('/connectors/paypal/v1/tokens', JSON.stringify(paypalPayload), { authenticate: true });
Expand Down
3 changes: 3 additions & 0 deletions packages/common/types/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export type GetOrderResponse = {

export type PaymentWithoutDetailsPayload = {
orderId: number;
captchaValue?: string;
};

export type PaymentWithAdyenPayload = PayloadWithIPOverride & {
Expand All @@ -266,6 +267,7 @@ export type PaymentWithPayPalPayload = {
errorUrl: string;
waitingUrl: string;
couponCode?: string;
captchaValue?: string;
};

export type PaymentWithPayPalResponse = {
Expand Down Expand Up @@ -304,6 +306,7 @@ export type InitialAdyenPaymentPayload = {
customerIP?: string;
browserInfo?: unknown;
enable3DSRedirectFlow?: boolean;
captchaValue?: string;
};

export type AdyenAction = {
Expand Down
4 changes: 2 additions & 2 deletions packages/hooks-react/src/useCheckout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const useCheckout = ({ onUpdateOrderSuccess, onSubmitPaymentWithoutDetailsSucces
onSuccess: onUpdateOrderSuccess,
});

const submitPaymentWithoutDetails = useMutation<Payment, Error>({
const submitPaymentWithoutDetails = useMutation<Payment, Error, { captchaValue?: string }>({
mutationKey: ['submitPaymentWithoutDetails'],
mutationFn: checkoutController.paymentWithoutDetails,
onSuccess: async () => {
Expand All @@ -55,7 +55,7 @@ const useCheckout = ({ onUpdateOrderSuccess, onSubmitPaymentWithoutDetailsSucces
const submitPaymentPaypal = useMutation<
{ redirectUrl: string },
Error,
{ successUrl: string; waitingUrl: string; cancelUrl: string; errorUrl: string; couponCode: string }
{ successUrl: string; waitingUrl: string; cancelUrl: string; errorUrl: string; couponCode: string; captchaValue?: string }
>({
mutationKey: ['submitPaymentPaypal'],
mutationFn: checkoutController.paypalPayment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { formatPrice } from '@jwp/ott-common/src/utils/formatting';
import Close from '@jwp/ott-theme/assets/icons/close.svg?react';
import PayPal from '@jwp/ott-theme/assets/icons/paypal.svg?react';
import CreditCard from '@jwp/ott-theme/assets/icons/creditcard.svg?react';
import type { ReCAPTCHA } from 'react-google-recaptcha';

import Button from '../Button/Button';
import IconButton from '../IconButton/IconButton';
import FormFeedback from '../FormFeedback/FormFeedback';
import DialogBackButton from '../DialogBackButton/DialogBackButton';
import LoadingOverlay from '../LoadingOverlay/LoadingOverlay';
import Icon from '../Icon/Icon';
import RecaptchaField from '../RecaptchaField/RecaptchaField';

import styles from './CheckoutForm.module.scss';

Expand All @@ -36,6 +38,8 @@ type Props = {
offerType: OfferType;
children: ReactNode;
submitting: boolean;
captchaSiteKey?: string;
recaptchaRef?: React.RefObject<ReCAPTCHA>;
};

const CheckoutForm: React.FC<Props> = ({
Expand All @@ -58,6 +62,8 @@ const CheckoutForm: React.FC<Props> = ({
onRedeemCouponButtonClick,
children,
submitting,
captchaSiteKey,
recaptchaRef,
}) => {
const { t } = useTranslation('account');

Expand Down Expand Up @@ -127,6 +133,7 @@ const CheckoutForm: React.FC<Props> = ({
<Button variant="outlined" label={t('checkout.redeem_coupon')} onClick={onRedeemCouponButtonClick} />
)}
</div>

<div>
<table className={styles.orderTotals}>
<tbody>
Expand Down Expand Up @@ -174,6 +181,7 @@ const CheckoutForm: React.FC<Props> = ({
</tfoot>
</table>
</div>
{!!captchaSiteKey && <RecaptchaField siteKey={captchaSiteKey} ref={recaptchaRef} />}
<hr className={styles.divider} />
{order.requiredPaymentDetails ? (
<div className={styles.paymentMethods}>
Expand Down
29 changes: 26 additions & 3 deletions packages/ui-react/src/containers/AccountModal/forms/Checkout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router';
import useCheckout from '@jwp/ott-hooks-react/src/useCheckout';
import { modalURLFromLocation, modalURLFromWindowLocation } from '@jwp/ott-ui-react/src/utils/location';
Expand All @@ -7,6 +7,9 @@ import { FormValidationError } from '@jwp/ott-common/src/errors/FormValidationEr
import { useTranslation } from 'react-i18next';
import { createURL } from '@jwp/ott-common/src/utils/urlFormatting';
import { findDefaultCardMethodId } from '@jwp/ott-common/src/utils/payments';
import type { ReCAPTCHA } from 'react-google-recaptcha';
import { useConfigStore } from '@jwp/ott-common/src/stores/ConfigStore';
import useEventCallback from '@jwp/ott-hooks-react/src/useEventCallback';

import CheckoutForm from '../../../components/CheckoutForm/CheckoutForm';
import LoadingOverlay from '../../../components/LoadingOverlay/LoadingOverlay';
Expand All @@ -30,6 +33,10 @@ const Checkout = () => {
const welcomeUrl = modalURLFromLocation(location, 'welcome');
const closeModalUrl = modalURLFromLocation(location, null);

const recaptchaRef = useRef<ReCAPTCHA>(null);
const captchaSiteKey = useConfigStore(({ config }) => (config.custom?.captchaSiteKey ? (config.custom?.captchaSiteKey as string) : undefined));
const getCaptchaValue = useEventCallback(async () => (captchaSiteKey ? (await recaptchaRef.current?.executeAsync()) || undefined : undefined));

const backButtonClickHandler = () => navigate(chooseOfferUrl);

const { selectedOffer, offerType, paymentMethods, order, isSubmitting, updateOrder, submitPaymentWithoutDetails, submitPaymentPaypal, submitPaymentStripe } =
Expand Down Expand Up @@ -134,8 +141,19 @@ const Checkout = () => {
couponFormSubmitting={couponFormSubmitting}
couponFormError={errors.couponCode}
submitting={isSubmitting || adyenUpdating}
captchaSiteKey={captchaSiteKey}
recaptchaRef={recaptchaRef}
>
{noPaymentRequired && <NoPaymentRequired onSubmit={submitPaymentWithoutDetails.mutateAsync} error={submitPaymentWithoutDetails.error?.message || null} />}
{noPaymentRequired && (
<NoPaymentRequired
onSubmit={async () => {
const captchaValue = await getCaptchaValue();

return submitPaymentWithoutDetails.mutateAsync({ captchaValue });
}}
error={submitPaymentWithoutDetails.error?.message || null}
/>
)}
{isStripePayment && (
<PaymentForm
onPaymentFormSubmit={async (cardPaymentPayload: PaymentFormData) =>
Expand All @@ -150,12 +168,17 @@ const Checkout = () => {
setUpdatingOrder={setAdyenUpdating}
orderId={order.id}
type="card"
getCaptchaValue={getCaptchaValue}
/>
</>
)}
{isPayPalPayment && (
<PayPal
onSubmit={() => submitPaymentPaypal.mutate({ successUrl: successUrlPaypal, waitingUrl, cancelUrl, errorUrl, couponCode })}
onSubmit={async () => {
const captchaValue = await getCaptchaValue();

submitPaymentPaypal.mutate({ successUrl: successUrlPaypal, waitingUrl, cancelUrl, errorUrl, couponCode, captchaValue });
}}
error={submitPaymentPaypal.error?.message || null}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ type Props = {
type: AdyenPaymentMethodType;
paymentSuccessUrl: string;
orderId?: number;
getCaptchaValue: () => Promise<string | undefined>;
};

export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuccessUrl, orderId }: Props) {
export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuccessUrl, orderId, getCaptchaValue }: Props) {
const accountController = getModule(AccountController);
const checkoutController = getModule(CheckoutController);
const { t } = useTranslation(['account', 'error']);
Expand Down Expand Up @@ -65,11 +66,13 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuc
return;
}

const captchaValue = await getCaptchaValue();

const returnUrl = createURL(window.location.href, {
u: 'finalize-payment',
orderId: orderId,
});
const result = await checkoutController.initialAdyenPayment(state.data.paymentMethod, returnUrl);
const result = await checkoutController.initialAdyenPayment(state.data.paymentMethod, returnUrl, captchaValue);

if ('action' in result) {
handleAction(result.action);
Expand All @@ -86,7 +89,7 @@ export default function AdyenInitialPayment({ setUpdatingOrder, type, paymentSuc

setUpdatingOrder(false);
},
[setUpdatingOrder, orderId, checkoutController, accountController, announce, t, navigate, paymentSuccessUrl],
[setUpdatingOrder, orderId, checkoutController, accountController, announce, t, navigate, paymentSuccessUrl, getCaptchaValue],
);

const adyenConfiguration: CoreOptions = useMemo(
Expand Down

0 comments on commit 4c6adfc

Please sign in to comment.