Broadleaf Microservices
  • v1.0.0-latest-prod

Stripe 3DS

Prerequisites

Before getting started…​

The Big Picture

When an Authorize or AuthorizeAndCapture transaction is attempted with Stripe, Radar Rules are used to determine if 3DS verification is required before the transaction is permitted.

This means that when using Stripe, you should always attempt your checkout transactions as you normally would, but if 3DS verification is required, then the CheckoutResponse#failureType will be PAYMENT_REQUIRES_3DS_VERIFICATION & the related payment transaction failure detail (CheckoutResponse#paymentTransactionFailureDetails) will have a failureType of PAYMENT_REQUIRES_3DS_VERIFICATION & a populated threeDSecureVerificationUrl. The user’s browser should be redirected to this url to complete the verification process.

Once the verification process is completed, the checkout attempt can be resubmitted where we’ll attempt to lookup the 3DS transaction results & continue the checkout process if the transaction was successful. Simultaneously, we also leverage webhook events as a secondary means of gathering & recording transaction results.

Configuration

Stripe Dashboard Configuration

Stripe has built-in rules related 3DS, but you can customize the rules via the Stripe dashboard, if you wish to do so.

Cart Operation Service Configuration

As mentioned in the Broadleaf 3DS docs, one of the primary ways for understanding transaction results after 3DS verification is successfully completed.

To enable this transaction lookup in the checkout workflow, add the following required property:

broadleaf:
  cartoperation:
    service:
      checkout:
        checkout-lookup-3ds-transactions-enabled:
          STRIPE: true

Configuring your Stripe Webhooks

As part of the Stripe 3DS solution, we use webhooks to ensure that transaction results are known by the Broadleaf ecosystem.

In the Stripe Dashboard, declare the webhook url using the following structure to hit the PaymentTransactionServices webhook endpoint: https://${host}/api/payment/webhooks/STRIPE

Note
Make sure to replace ${host} with the value relevant to your environment

To ensure the validity of inbound requests, we confirm that the request’s signature matches the value that we calculate using a secret key provided by Stripe. To define this key in your Broadleaf ecosystem, the following property must be declared:

broadleaf:
  stripe:
    rest:
      webhook-endpoint-secret: {Your webhook endpoint secret}
Note

If your solution requires different Stripe accounts per tenant or per application, then you’ll want to leverage the ability to provide application-discriminated and/or tenant-discriminated values for the webhook secret.

For example:

  • broadleaf.stripe.rest.tenant.mytenant.webhook-endpoint-secret=…​

  • broadleaf.stripe.rest.application.myapplication.webhook-endpoint-secret=…​

"mytenant" & "myapplication" being the Broadleaf Tenant and Application ids.

To correctly identify & engage these application-discriminated and/or tenant-discriminated webhook secrets, you’ll need to pass the relevant application and/or tenant ids as parameters via the webhook requests. This should be done by defining the webhook url using the following parameters: https://${host}/api/payment/webhooks/STRIPE?applicationId=myapplication&tenantId=mytenant

Declaring the Return Url to be used after 3DS Verification

When the customer finishes the authentication process, the redirect sends them back to the declared return url.

An example of how to build this return URL from your frontend application:

const protocol = window.location.protocol.slice(0, -1);
const host = window.location.host;

const paymentIntentReturnURL = `${protocol}://${host}/checkout/3ds-verification?gatewayType=STRIPE`

The return url should be declared when creating the payment in PaymentTransactionServices, using the PAYMENT_INTENT_RETURN_URL key in the paymentMethodProperties map.

{
  paymentMethodProperties: {
    ...
    PAYMENT_INTENT_RETURN_URL: "https://{your_host}/checkout/3ds-verification?gatewayType=STRIPE",
    ...
  }
}
Note
In our demo application, Heat Clinic, the frontend application handles the request to this return url, so that it becomes aware of the verification result & can progress to the next step in the checkout process. We recommend creating a separate page to check the 3DS verification result. In our example below, we use /checkout/3ds-verification page to do so.

Rendering the 3DS Verification Page & Handling the Return Url Request

When attempting a checkout, if you receive a CheckoutResponse with failureType = PAYMENT_REQUIRES_3DS_VERIFICATION, then you’ll need to gather the threeDSecureVerificationUrl from the CheckoutResponse#paymentTransactionFailureDetails & render the 3DS verification page. Depending on your needs, it’s possible to redirect to this url or use an iframe. The example below supports both approaches.

Once the verification step is completed, the browser will be redirected back to the location declared by Payment#paymentMethodProperties#PAYMENT_INTENT_RETURN_URL. If the verification was successful, then we suggest automatically submitting checkout. If the verification was not successful, then we suggest providing a message to the customer highlighting the failure & requesting a different form of payment.

Note
The /checkout/3ds-verification page is used to check the status of the 3DS authentication.
import React, { FC, useEffect, useState } from 'react';

import { useRouter } from 'next/router';
import { isEmpty, set, omit } from 'lodash';

import { useHandleTransactionResult } from '@broadleaf/payment-react';

const ThreeDSecureVerification: FC = () => {
  const router = useRouter();
  const { query } = router;

  // the PaymentClient from '@broadleaf/commerce-cart'
  const paymentClient = ...;
  // the AuthState from '@broadleaf/payment-js'
  const authState = ...;

  const [transactionResult, setTransactionResult] =
    useState<Record<string, unknown>>();
  const { handleTransactionResult, error } = useHandleTransactionResult({
    paymentClient,
    authState,
  });

  const protocol = window.location.protocol.slice(0, -1);
  const host = window.location.host;
  const search = window.location.search;

  // Redirect to this page if the 3DS authentication is successful and this page is not opened in the iframe
  const verificationSuccessRedirectUrl = `${protocol}://${host}/checkout/review?3ds-verification-success=true`;
  // Redirect to this page if the 3DS authentication is failed
  const verificationFailureRedirectUrl = `${protocol}://${host}/checkout/review?3ds-verification-success=false`;
  // The 3DS verification can be successful, however, the 3DS result transaction itself (AUTHORIZE) can fail
  const transactionFailureRedirectUrl = `${protocol}://${host}/checkout/review?$3ds-transaction-success=false`;

  const threeDSVerificationResult = query['3ds-verification-success'];

  useEffect(() => {
    if (threeDSVerificationResult !== 'false') {
      const { gatewayType, ...request } = parseParams(search);

      const requestParams = omitRequestParamsForGatewayType(
        gatewayType,
        request
      );
      handleTransactionResult(gatewayType, requestParams).then(response => {
        setTransactionResult(response);
      });
    } else {
      redirectTo(verificationFailureRedirectUrl);
    }
  }, [
    verificationFailureRedirectUrl,
    handleTransactionResult,
    router,
    search,
    threeDSVerificationResult,
  ]);

  const transactionStatus =
    transactionResult?.transactionExecutionDetails[0]?.transactionStatus;

  if (
    transactionResult &&
    transactionStatus !== 'SUCCESS' &&
    transactionStatus !== 'AWAITING_ASYNC_RESULTS'
  ) {
    redirectTo(transactionFailureRedirectUrl);
  }

  if (error) {
    /*
    if there's an error, we'll still submit the order after the timeout to leverage other 3DS
    verification fall back options, e.g. webhook & 3DS result lookup. The timeout is to ensure
    webhooks would be processed by the time we re-submit the order
     */
    console.error(
      'Error encountered while verifying 3DS transaction result: ',
      error
    );
    setTimeout(
      () => redirectOrSubmitOrder(verificationSuccessRedirectUrl),
      3000
    );
  }

  if (
    transactionStatus === 'SUCCESS' ||
    transactionStatus === 'AWAITING_ASYNC_RESULTS'
  ) {
    redirectOrSubmitOrder(verificationSuccessRedirectUrl);
  }

  // show any loader
  return <PageLoader loading={true} />;
};

const parseParams = (search?: string): Record<string, never> => {
  if (isEmpty(search)) {
    return {};
  }

  search = search?.startsWith('?') ? search.slice(1) : search;

  return search
    ?.split('&')
    .map(param => {
      // eslint-disable-next-line prefer-const
      let [key, value] = param.split('=');
      key = decodeURIComponent(key);
      return { key, value };
    })
    .reduce((accumulator, { key, value }) => {
      set(accumulator, key, value);
      return accumulator;
    }, {});
};

const redirectOrSubmitOrder = verificationSuccessRedirectUrl => {
  if (isOpenedInIframe()) {
    // submit the order if this page is opened in the iframe
    // 'submit-order-button' - the id of the button to submit the order
    window.parent.document.getElementById('submit-order-button').click();
  } else {
    // the order will be submitted on the review page if '3ds-verification-success=true'
    window.location.href = verificationSuccessRedirectUrl;
  }
};

const redirectTo = redirectUrl => {
  if (isOpenedInIframe()) {
    window.parent.location.href = redirectUrl;
  } else {
    window.location.href = redirectUrl;
  }
};

const isOpenedInIframe = (): boolean => {
  return window !== window.top;
};

const omitRequestParamsForGatewayType = (
  gatewayType: string,
  requestParams?: Record<string, string>
): Record<string, string> => {
  if ('STRIPE' === gatewayType) {
    // do not send the client secret key
    return omit(requestParams, ['payment_intent_client_secret']);
  }

  return requestParams;
};

Order Review Page

This page is used to review and submit the order. If the payment requires 3DS authentication, the submission will fail and the response will contain the bank’s page URL to verify 3DS. This URL can be used to redirect the customer or load the page in the iframe.

import { FC, useState, useEffect } from 'react';

import { useRouter } from 'next/router';
import { get, find } from 'lodash';

const CheckoutReview: FC = () => {
  const router = useRouter();
  const { query } = router;
  // resolve cart
  const { cart, resolving } = ...;

  // useHandleSubmitCart - your hook implementation to submit the order
  const { error, submitting, onSubmit } = useHandleSubmitCart();

  const [threeDSecureVerificationUrl, setThreeDSecureVerificationUrl] =
    useState<string>();

  const threeDSVerificationResult = query['3ds-verification-success'];

  useEffect(() => {
    if (threeDSVerificationResult === 'true') {
      if (!resolving && cart) {
        // submit the order if the 3DS verification result is successful
        // this is used when the redirect is used instead of iframe
        onSubmit();
      }

      if (error) {
        // delete '3ds-verification-success' parameter from the URL
        delete router.query['3ds-verification-success'];
        router.replace(router, undefined, { shallow: true });
      }
    }
  }, [threeDSVerificationResult, router, onSubmit, cart, error, resolving]);

  useEffect(() => {
    if ( get(error, 'failureType') === 'PAYMENT_REQUIRES_3DS_VERIFICATION') {
      const threeDSDetails = find(
        get(error, 'paymentTransactionFailureDetails'),
        ({ failureType }) => failureType === 'REQUIRES_3DS_VERIFICATION'
      );

      const verificationUrl = get(
        threeDSDetails,
        'threeDSecureVerificationUrl'
      );

      if (verificationUrl) {
        // isRedirectToBankPage - your implementation that is used to control whether the customer is redirected to the bank's page or it is loaded in the iframe
        if (isRedirectToBankPage()) {
          // redirect to the 3DS verification page instead of using the iframe
          router.push(verificationUrl);
        } else {
          // this URL will be loaded using the iframe in the modal dialog
          setThreeDSecureVerificationUrl(verificationUrl);
        }
      }
    }
  }, [error, router]);

  const threeDSVerificationFailedError =
    router.query['3ds-verification-success'] === 'false'
      ? '3DS verification failed'
      : undefined;

  const threeDSTransactionFailedError =
    router.query['3ds-transaction-success'] === 'false'
      ? 'We are unable to process your payment'
      : undefined;
  return (
    <div>
      {threeDSVerificationFailedError && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {threeDSVerificationFailedError}
        </strong>
      )}
      {threeDSTransactionFailedError && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {threeDSTransactionFailedError}
        </strong>
      )}

      ...

      <SubmitButton
          id="submit-order-button"
          disabled={submitting}
          label='Submit Order'
          onClick={onSubmit}
          type="button"
        />

      <Modal
        setIsOpen={() => setThreeDSecureVerificationUrl(undefined)}
        isOpen={!!threeDSecureVerificationUrl && !submitting}
        title='D-Secure Authentication'
        onCancel={() => {
          setThreeDSecureVerificationUrl(undefined);
        }}
      >
        <iframe
          id="3ds"
          src={threeDSecureVerificationUrl}
          className="flex-grow sm:w-full"
          height="500"
        />
      </Modal>
    </div>
  );
};