Broadleaf Microservices
  • v1.0.0-latest-prod

Apple and Google Pay Integration with Stripe

Prerequisites

Before you get started with the integration…​

Cart Operation Service Configuration

Add the following properties to declare ApplePay & GooglePay as available payment methods with Stripe:

broadleaf:
  cartoperation:
    service:
        checkout:
          checkout-payment-method-options:
            - payment-method-type: APPLE_PAY
              payment-method-gateway-type: STRIPE
            - payment-method-type: GOOGLE_PAY
              payment-method-gateway-type: STRIPE

ApplePay & GooglePay buttons with Stripe Elements

To render the ApplePay and/or GooglePay buttons, you’ll need to make use of the Stripe Elements frontend library. The Stripe docs do a great job of describing the interaction, but the example integrations described below have a key difference:

We leverage the Stripe Elements integration to provide a Stripe PaymentMethod id, which is provided to PaymentTransactionServices. Once checkout is submitted, PaymentTransactionServices will engage this broadleaf-stripe module to create & confirm a Stripe PaymentIntent, causing the execution of an Authorize or AuthorizeAndCapture transaction.

Note

In the Stripe docs, especially pay attention to the conditions that must be met for each button to be rendered. This will be very important when you begin testing your integration!

Also note that Stipe’s Link wallet is used as a fall-back option if the ApplePay and GooglePay conditions are not met.

Within the Checkout Flow

ApplePay & GooglePay backed by Stripe can be introduced as additional options into the payment section of your checkout experience. The following example shows how your frontend application can be setup to render the ApplePay or GooglePay buttons within your checkout UI, gather a Stripe payment method, & prepare payment data in Broadleaf.

Example Frontend Integration

To integrate the frontend with the Stripe Elements load the Stripe.js as an ECMAScript module.

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import {loadStripe} from '@stripe/stripe-js';
import { StripeProvider } from '@app/checkout/components/stripe-payment/context/stripe-context';
const root = ReactDOM.createRoot(document.getElementById('root'));
const stripePromise = loadStripe('pk_test_');
root.render(
  <React.StrictMode>
    <StripeProvider stripePromise={stripePromise}>
      <App />
    </StripeProvider>
  </React.StrictMode>
);
import React, { FC, useState } from 'react';
import { useRouter } from 'next/router';

import {
  ExpressCheckoutElement,
  useElements,
  useStripe,
} from '@stripe/react-stripe-js';
import { PaymentMethod as StripePaymentMethod } from '@stripe/stripe-js';

import { capitalize, indexOf, padStart } from 'lodash';
import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import {
  useFormatAmountInMinorUnits,
  useGetPaymentCallbackUrl,
  useGetRemainingTotalToPay,
  useHandleMoneyAddition,
} from '@app/checkout/hooks';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { maskCardNumber } from '@broadleaf/payment-js';
import { usePaymentClient } from '@app/common/contexts';
import { useFormatMessage, usePaymentAuthState } from '@app/common/hooks';
import {
  Address,
  Cart,
  DefaultPaymentType,
  PaymentRequest,
  PaymentSummary,
  Phone,
} from '@broadleaf/commerce-cart';
import {
  hasAnyVirtualFulfillmentGroup,
  pushGtmAddPayment,
} from '@app/common/utils';
import { useRepriceCart } from '@app/cart/hooks';
import type { MonetaryAmount } from '@broadleaf/commerce-browse';

import { StripePaymentForm } from './stripe-payment-form';

import messages from '@app/checkout/messages';
import {
  StripeExpressCheckoutElementConfirmEvent,
  StripeExpressCheckoutElementOptions,
} from '@stripe/stripe-js/dist/stripe-js/elements/express-checkout';

type Props = {
  enabledPaymentTypes: Array<string>;
};

const StripeWallet: FC<Props> = ({ enabledPaymentTypes }) => {
  const formatMessage = useFormatMessage();
  const cartState = useCartContext();
  const { cart, setCart } = cartState;
  const { payments } = usePaymentsContext();
  const handleMoneyAddition = useHandleMoneyAddition();
  const getRemainingTotalToPay = useGetRemainingTotalToPay();
  const remainingTotalToPay = getRemainingTotalToPay();
  const existingPayment = payments?.content?.filter(
    p => p.gatewayType === StripePaymentForm.TYPE
  )[0];
  const hasExistingAmount = !!existingPayment?.amount?.amount;
  const amount: MonetaryAmount = hasExistingAmount
    ? {
        amount: handleMoneyAddition(
          existingPayment.amount,
          remainingTotalToPay
        ),
        currency: existingPayment.amount.currency,
      }
    : remainingTotalToPay;
  const [error, setError] = useState(null);

  const options = useStripeExpressElementOptions(enabledPaymentTypes);
  const { createPayment } = useCreatePayment(cart, setCart, setError);

  return (
    <div>
      <ExpressCheckoutElement
        className="stripe-wallet-button"
        onConfirm={event => createPayment(amount, existingPayment, event)}
        options={options}
      />
      {error && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {formatMessage(messages.genericError)}
        </strong>
      )}
    </div>
  );
};

const useStripeExpressElementOptions = (
  enabledPaymentTypes: Array<string>
): StripeExpressCheckoutElementOptions => {
  return {
    buttonType: {
      googlePay: 'buy',
      applePay: 'book',
      paypal: 'buynow',
    },
    buttonTheme: {
      applePay: 'black',
    },
    paymentMethods: {
      googlePay:
        indexOf(enabledPaymentTypes, DefaultPaymentType.GOOGLE_PAY) !== -1
          ? 'always'
          : 'never',
      applePay:
        indexOf(enabledPaymentTypes, DefaultPaymentType.APPLE_PAY) !== -1
          ? 'always'
          : 'never',
    },
    buttonHeight: 55,
    emailRequired: true,
  };
};

const useCreatePayment = (cart: Cart, setCart, setError) => {
  const router = useRouter();
  const getRemainingTotalToPay = useGetRemainingTotalToPay();

  const { payments, refetchPayments } = usePaymentsContext();
  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();
  const { repriceCart, error: repriceError } = useRepriceCart();
  const elements = useElements();
  const stripe = useStripe();
  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
    authState,
    payments,
    ownerId: cart?.id,
    owningUserEmailAddress: cart.emailAddress,
    paymentClient,
    multiplePaymentsAllowed: true,
    rejectOnError: true,
  });

  const createPayment = async (
    amount: MonetaryAmount,
    existingPayment: PaymentSummary,
    stripeEvent: StripeExpressCheckoutElementConfirmEvent
  ) => {
    elements.update({ amount: formatAmountInMinorUnits(amount) });
    const { error: submitError } = await elements.submit();
    if (submitError) {
      stripeEvent.paymentFailed({ reason: 'fail' });
      setError(submitError.message);
      return;
    }

    const redirectURL = getPaymentCallbackUrl({
      gatewayType: StripePaymentForm.TYPE,
    });

    const { error, confirmationToken } = await stripe.createConfirmationToken({
      elements,
      params: {
        payment_method_data: {
          billing_details: stripeEvent.billingDetails,
        },
        return_url: redirectURL,
      },
    });
    if (error) {
      // This point is only reached if there's an immediate error when
      // creating the ConfirmationToken. Show the error to your customer (for example, payment details incomplete)
      setError(error.message);
      stripeEvent.paymentFailed({ reason: 'fail' });
      return;
    }
    const paymentMethodProperties = {
      CONFIRMATION_TOKEN_ID: confirmationToken.id,
    };
    const walletName =
      confirmationToken.payment_method_preview.card.wallet?.type;
    const stripeCard: StripePaymentMethod.Card =
      confirmationToken.payment_method_preview.card;
    const displayAttributes = {
      creditCardType: stripeCard.brand.toUpperCase(),
      creditCardNumber: maskCardNumber(stripeCard.last4),
      creditCardExpDateMonth: padStart(`${stripeCard.exp_month}`, 2, '0'),
      creditCardExpDateYear: `${stripeCard.exp_year}`,
    };
    console.log('walletName', walletName);
    const paymentName = `${capitalize(walletName)} | ${
      displayAttributes.creditCardType
    } | ${displayAttributes.creditCardNumber} ${
      displayAttributes.creditCardExpDateMonth
    }/${displayAttributes.creditCardExpDateYear}`;
    console.log('stripeEvent.billingDetails', stripeEvent.billingDetails);
    console.log(
      'confirmationToken.payment_method_preview.billing_details',
      confirmationToken.payment_method_preview.billing_details
    );
    //check if stripeBillingDetails is present in this case.
    const stripeBillingDetails =
      stripeEvent.billingDetails.name &&
      stripeEvent.billingDetails.address.line1
        ? stripeEvent.billingDetails
        : confirmationToken.payment_method_preview.billing_details;
    const payerEmail = stripeBillingDetails.email;
    const payerName = stripeBillingDetails.name;
    const phonePrimary: Phone = stripeBillingDetails?.phone
      ? {
          phoneNumber: stripeBillingDetails.phone,
        }
      : cart.fulfillmentGroups[0].address.phonePrimary;
    let fullName;
    if (stripeBillingDetails?.name) {
      fullName = stripeBillingDetails.name;
    } else {
      if (cart.fulfillmentGroups?.length) {
        fullName = cart.fulfillmentGroups[0].address.fullName;
      }
      if (!fullName) {
        fullName = stripeBillingDetails.email;
      }
    }

    const billingAddress = {
      fullName: fullName,
      emailAddress: stripeBillingDetails?.email,
      addressLine1: stripeBillingDetails?.address?.line1,
      addressLine2: stripeBillingDetails?.address?.line2,
      city: stripeBillingDetails?.address?.city,
      stateProvinceRegion: stripeBillingDetails?.address?.state,
      country: stripeBillingDetails?.address?.country,
      postalCode: stripeBillingDetails?.address?.postal_code,
      phonePrimary,
    } as Address;

    let paymentType;

    if (stripeEvent?.expressPaymentType === 'apple_pay') {
      paymentType = DefaultPaymentType.APPLE_PAY;
    } else if (stripeEvent?.expressPaymentType === 'google_pay') {
      paymentType = DefaultPaymentType.GOOGLE_PAY;
    }

    const paymentRequest = {
      owningUserName: payerName,
      owningUserEmailAddress: payerEmail,
      name: paymentName,
      type: paymentType,
      gatewayType: StripePaymentForm.TYPE,
      amount,
      subtotal: cart.cartPricing.subtotal,
      adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
      fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
      taxTotal: cart.cartPricing.totalTax,
      isSingleUsePaymentMethod: true,
      billingAddress,
      paymentMethodProperties,
      displayAttributes,
    } as PaymentRequest;
    console.log('paymentRequest', paymentRequest);
    let paymentSummary;

    try {
      paymentSummary = await handleSubmitPaymentInfo(
        paymentRequest,
        existingPayment?.paymentId
      );

      if (paymentSummary) {
        // recalculate the taxes for the virtual fulfillment because it requires the billing payment address
        if (hasAnyVirtualFulfillmentGroup(cart)) {
          const newCart = await repriceCart();

          if (!newCart || repriceError) {
            // there was an error
            setError(repriceError);
          } else {
            setCart(newCart);
            pushGtmAddPayment(newCart, paymentSummary);
          }
        } else {
          pushGtmAddPayment(cart, paymentSummary);
        }
      } else {
        stripeEvent.paymentFailed({ reason: 'fail' });
      }
    } catch (err) {
      stripeEvent.paymentFailed({ reason: 'fail' });
      console.error('There was an error adding payment information', err);
      setError(err);
    } finally {
      await refetchPayments();
      const remainingTotalToPay = getRemainingTotalToPay(
        undefined,
        paymentSummary
      );

      if (
        paymentSummary &&
        (paymentSummary.amount.amount === remainingTotalToPay.amount ||
          !remainingTotalToPay.amount)
      ) {
        await router.push('/checkout/review');
      }
    }
  };

  return { createPayment };
};

export default StripeWallet;

Express Checkout

Express checkout with Apple Pay or Google Pay allows customers to provide all the required information like shipping address, shipping preference, and payment method using a simple dialog provided by Apple or Google, skipping your normal checkout experience.

Throughout this interaction with the ApplePay or GooglePay interfaces, we make updates to the customer’s cart to provide user, fulfillment, & payment data as it becomes available. At the end of the interaction, the cart has all of the information that would normally be gathered via your checkout UI, & the cart is ready for checkout submission.

Example Frontend Integration

Cart Page

import React, { useState } from 'react';


const Cart = () => {
  const [canUseExpressCheckout, setCanUseExpressCheckout] = useState<boolean | undefined>();

  return (
      <div>
          <CheckoutButton />
          {canUseExpressCheckout !== false && (
            <div className="w-full">
              {canUseExpressCheckout === true && (
                <div className="px-4 py-2 w-full text-center">
                  Or Checkout With
                </div>
              )}
              <StripeWalletExpressCheckout
                setCanUseExpressCheckout={setCanUseExpressCheckout}
              />
            </div>
          )}
      </div>
  )
};

Stripe Wallet Express Checkout

import React, { FC, useState } from 'react';
import classNames from 'classnames';
import {
  capitalize,
  find,
  indexOf,
  isEmpty,
  map,
  padStart,
  reduce,
} from 'lodash';

import {
  ClickResolveDetails,
  ConfirmationToken,
  StripeExpressCheckoutElementClickEvent,
  StripeExpressCheckoutElementShippingAddressChangeEvent,
  StripeExpressCheckoutElementShippingRateChangeEvent,
} from '@stripe/stripe-js';
import {
  ExpressCheckoutElement,
  useElements,
  useStripe,
} from '@stripe/react-stripe-js';
import messages from '@app/checkout/messages';
import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import {
  useCallbackFormatAmountInMinorUnits,
  useGetPaymentAmount,
  useGetPaymentCallbackUrl,
  useHandleSubmitCart,
  useUpdateFulfillmentGroup,
} from '@app/checkout/hooks';
import { StripePaymentForm } from '@app/checkout/components';
import { MonetaryAmount } from '@broadleaf/commerce-browse';
import {
  useEventCallback,
  useFormatMessage,
  usePaymentAuthState,
} from '@app/common/hooks';
import {
  Address,
  Cart,
  CheckoutClientCallOptions,
  DefaultFulfillmentType,
  DefaultPaymentType,
  FulfillmentAddress,
  GuestTokenResponse,
  PaymentRequest,
  PaymentSummary,
  Phone,
  PricedFulfillmentOption,
  UpdateFulfillmentGroupRequest,
} from '@broadleaf/commerce-cart';
import { useAuth } from '@broadleaf/auth-react';
import { useCsrContext } from '@app/csr/context';
import { areAddressesEquivalent, maskCardNumber } from '@broadleaf/payment-js';

import {
  hasAnyVirtualFulfillmentGroup,
  pushGtmAddPayment,
  pushGtmAddShipping,
} from '@app/common/utils';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import {
  useCheckoutClient,
  usePaymentClient,
  usePreviewOptions,
} from '@app/common/contexts';
import {
  useFetchFulfillmentOptionsForCartItems,
  useRepriceCart,
  useSelectFulfillmentOption,
  useUpdateFulfillmentGroupAddress,
} from '@app/cart/hooks';
import {
  getShipGroup,
  getShipGroupPriceFulfillmentOption,
  getShipGroupReferenceNumber,
} from '@app/cart/utils';
import { useGetCustomerAccessToken } from '@app/auth/hooks';
import {
  BillingDetails,
  ExpressCheckoutPartialAddress,
  ShippingAddress,
  StripeExpressCheckoutElementConfirmEvent,
  StripeExpressCheckoutElementOptions,
  StripeExpressCheckoutElementReadyEvent,
} from '@stripe/stripe-js/dist/stripe-js/elements/express-checkout';
import { PaymentMethod } from '@stripe/stripe-js/dist/api/payment-methods';

type Props = {
  className?: string;
  enabledPaymentTypes: Array<string>;
  setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void;
};

export const StripeWalletExpressCheckout: FC<Props> = ({
  className,
  setCanUseExpressCheckout,
  enabledPaymentTypes,
}) => {
  const formatMessage = useFormatMessage();

  const cartState = useCartContext();
  const { resolving: resolvingCart, cart } = cartState;

  const fulfillmentGroup = getShipGroup(cart);
  const fulfillmentType = fulfillmentGroup?.type;

  const isVirtualFulfillment =
    fulfillmentType === DefaultFulfillmentType.VIRTUAL;

  const [error, setError] = useState(null);

  const onShippingAddressChange = useOnShippingAddressChange(setError);
  const onShippingOptionChange = useOnShippingOptionChange(setError);
  const onPaymentMethod = useOnPaymentMethod(setError);
  const options = useStripeExpressElementOptions(
    isVirtualFulfillment,
    enabledPaymentTypes
  );

  const handleStartCheckout = useHandleStartCheckout();

  const onReady = useHandleOnReady(
    setCanUseExpressCheckout,
    enabledPaymentTypes
  );

  if (resolvingCart || !cart) {
    return null;
  }
  return (
    <div className={classNames(className, 'stripe-express-checkout')}>
      <ExpressCheckoutElement
        onConfirm={onPaymentMethod}
        onShippingAddressChange={onShippingAddressChange}
        onShippingRateChange={onShippingOptionChange}
        options={options}
        onClick={handleStartCheckout}
        onReady={onReady}
      />
      {error && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {formatMessage(messages.genericError)}
        </strong>
      )}
    </div>
  );
};

const useStripeExpressElementOptions = (
  isVirtualFulfillment: boolean,
  enabledPaymentTypes: Array<string>
): StripeExpressCheckoutElementOptions => {
  return {
    buttonType: {
      googlePay: 'buy',
      applePay: 'book',
      paypal: 'buynow',
    },
    buttonTheme: {
      applePay: 'black',
    },
    paymentMethods: {
      googlePay:
        indexOf(enabledPaymentTypes, DefaultPaymentType.GOOGLE_PAY) !== -1
          ? 'always'
          : 'never',
      applePay:
        indexOf(enabledPaymentTypes, DefaultPaymentType.APPLE_PAY) !== -1
          ? 'always'
          : 'never',
    },
    buttonHeight: 55,
    emailRequired: true,
    phoneNumberRequired: true,
    shippingAddressRequired: !isVirtualFulfillment,
  };
};

const useHandleOnReady = (
  setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void,
  enabledPaymentTypes
) => {
  return useEventCallback(
    (event: StripeExpressCheckoutElementReadyEvent) => {
      const availablePaymentMethods = event.availablePaymentMethods;
      if (
        (availablePaymentMethods.googlePay &&
          indexOf(enabledPaymentTypes, DefaultPaymentType.GOOGLE_PAY) !== -1) ||
        (availablePaymentMethods.applePay &&
          indexOf(enabledPaymentTypes, DefaultPaymentType.APPLE_PAY) !== -1)
      ) {
        setCanUseExpressCheckout(true);
      }
    },
    [setCanUseExpressCheckout, enabledPaymentTypes]
  );
};

const useHandleStartCheckout = () => {
  const { cart } = useCartContext();

  const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();

  return async (event: StripeExpressCheckoutElementClickEvent) => {
    const lineItems = map(cart.cartItems, cartItem => {
      const isMerch =
        cartItem.internalAttributes.productType === 'MERCHANDISING_PRODUCT';
      const itemTotal = isMerch
        ? cartItem.totalWithDependentItems
        : cartItem.total;
      const cartItemAmount = formatAmountInMinorUnits(itemTotal);
      const label = `${cartItem.name} (${cartItem.quantity})`;

      return {
        amount: cartItemAmount,
        name: label,
      };
    });
    event.resolve({ lineItems: lineItems } as ClickResolveDetails);
  };
};

const useOnShippingAddressChange = setError => {
  const { setCart } = useCartContext();

  const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();

  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: StripePaymentForm.TYPE,
  });

  const { updateFulfillmentGroupAddress } = useUpdateFulfillmentGroupAddress();

  const buildShippingOptions = useBuildShippingOptions();

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken(setError);

  const { isAuthenticated, user } = useAuth();

  const elements = useElements();

  return async (
    event: StripeExpressCheckoutElementShippingAddressChangeEvent
  ) => {
    let newCart: Cart;

    try {
      newCart = await updateContactInfoOrGenerateGuestToken(
        isAuthenticated ? user?.email_address : undefined
      );

      const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);

      const fulfillmentAddress = buildFulfillmentAddress(event.address);
      const response = await updateFulfillmentGroupAddress(
        fulfillmentAddress,
        newCart,
        shipGroupReferenceNumber
      );

      newCart = response?.cart;

      if (newCart) {
        setCart(newCart);

        const fulfillmentOptions =
          response.fulfillmentOptionResponse.groupFulfillmentOptions[
            shipGroupReferenceNumber
          ];

        const shippingOptions = buildShippingOptions(
          fulfillmentOptions,
          newCart
        );
        const paymentAmount = getPaymentAmount(newCart);
        elements.update({
          amount: formatAmountInMinorUnits(paymentAmount),
        });
        event.resolve({ shippingRates: shippingOptions });
      } else {
        event.reject();
      }
    } catch (err) {
      event.reject();
      setError(err);
      console.error(
        'There was an error updating the checkout information',
        err
      );
    }
  };
};

const useOnShippingOptionChange = setError => {
  const { cart, setCart } = useCartContext();

  const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();

  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: StripePaymentForm.TYPE,
  });

  const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems(
    isEmpty(getShipGroup(cart)?.address)
  );

  const { selectFulfillmentOption } = useSelectFulfillmentOption();

  const buildShippingOptions = useBuildShippingOptions();

  const elements = useElements();

  return async (event: StripeExpressCheckoutElementShippingRateChangeEvent) => {
    let newCart: Cart;
    try {
      const stripeShippingOption = event.shippingRate;
      const selectedOption = find(fulfillmentOptions, [
        'description',
        stripeShippingOption.id,
      ]);
      const response = await selectFulfillmentOption(selectedOption);
      newCart = response?.cart;

      if (newCart) {
        setCart(newCart);

        const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);

        const fulfillmentOptions =
          response.fulfillmentOptionResponse.groupFulfillmentOptions[
            shipGroupReferenceNumber
          ];

        const shippingOptions = buildShippingOptions(
          fulfillmentOptions,
          newCart
        );
        const paymentAmount = getPaymentAmount(newCart);
        elements.update({
          amount: formatAmountInMinorUnits(paymentAmount),
        });
        event.resolve({ shippingRates: shippingOptions });
        pushGtmAddShipping(newCart);
      } else {
        event.reject();
      }
    } catch (err) {
      event.reject();
      setError(err);
      console.error(
        'There was an error updating the checkout information',
        err
      );
    }
  };
};

const useOnPaymentMethod = setError => {
  const { cart, setCart } = useCartContext();

  const { payments } = usePaymentsContext();
  const existingPayment = payments?.content?.filter(
    p => p.gatewayType === StripePaymentForm.TYPE
  )[0];

  const { repriceCart, error: repriceError } = useRepriceCart();

  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();

  const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
    authState,
    payments,
    ownerId: cart?.id,
    owningUserEmailAddress: cart.emailAddress,
    paymentClient,
    multiplePaymentsAllowed: true,
    rejectOnError: true,
  });

  const { onSubmit: submitCart } = useHandleSubmitCart();

  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: StripePaymentForm.TYPE,
  });

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken(setError);

  const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();

  const stripe = useStripe();
  const elements = useElements();
  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  return async (event: StripeExpressCheckoutElementConfirmEvent) => {
    let newCart: Cart;

    let paymentSummary: PaymentSummary;

    const fulfillmentGroup = getShipGroup(cart);
    const fulfillmentType = fulfillmentGroup?.type;

    const isVirtualFulfillment =
      fulfillmentType === DefaultFulfillmentType.VIRTUAL;

    try {
      newCart = await updateContactInfoOrGenerateGuestToken(
        event.billingDetails.email
      );

      if (!isVirtualFulfillment) {
        const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
        const shippingAddress = buildShippingAddress(
          event.shippingAddress,
          event.billingDetails
        );
        const currentShippingAddress = getShipGroup(newCart)?.address;

        const addressesEquivalent = areAddressesEquivalent(
          shippingAddress,
          currentShippingAddress
        );

        if (!addressesEquivalent) {
          const request = {
            address: shippingAddress,
          } as UpdateFulfillmentGroupRequest;
          newCart = await updateFulfillmentGroup(
            shipGroupReferenceNumber,
            request,
            newCart
          );

          if (newCart) {
            setCart(newCart);
          } else {
            event.paymentFailed({ reason: 'fail' });
            setError(true);
            return;
          }
        }
      }

      const { error: submitError } = await elements.submit();
      if (submitError) {
        setError(submitError.message);
        return;
      }

      const redirectURL = getPaymentCallbackUrl({
        gatewayType: StripePaymentForm.TYPE,
      });
      const { error, confirmationToken } = await stripe.createConfirmationToken(
        {
          elements,
          params: {
            payment_method_data: {
              billing_details: event.billingDetails,
            },
            return_url: redirectURL,
          },
        }
      );

      if (error) {
        // This point is only reached if there's an immediate error when
        // creating the ConfirmationToken. Show the error to your customer (for example, payment details incomplete)
        setError(error.message);
        event.paymentFailed({ reason: 'fail' });
        return;
      }
      const paymentRequest = buildPaymentRequest(
        event,
        getPaymentAmount(newCart),
        newCart,
        confirmationToken
      );
      paymentSummary = await handleSubmitPaymentInfo(
        paymentRequest,
        existingPayment?.paymentId
      );

      if (paymentSummary) {
        // recalculate the taxes for the virtual fulfillment because it requires the billing payment address
        if (hasAnyVirtualFulfillmentGroup(newCart)) {
          newCart = await repriceCart();

          if (!newCart || repriceError) {
            // there was an error
            setError(repriceError || true);
            return;
          } else {
            setCart(newCart);
          }
        }

        pushGtmAddPayment(newCart, paymentSummary);
        await submitCart();
      } else {
        event.paymentFailed({ reason: 'fail' });
      }
    } catch (err) {
      setError(err);
      event.paymentFailed({ reason: 'fail' });
      console.error('An error occurred while processing the request', err);
    }
  };
};

const buildPaymentRequest = (
  event: StripeExpressCheckoutElementConfirmEvent,
  paymentAmount: MonetaryAmount,
  cart: Cart,
  confirmationToken: ConfirmationToken
): PaymentRequest => {
  const paymentMethodProperties = {
    CONFIRMATION_TOKEN_ID: confirmationToken.id,
  };

  const stripeBillingDetails = event.billingDetails;

  const stripeCard: PaymentMethod.Card =
    confirmationToken.payment_method_preview.card;

  const phonePrimary: Phone = stripeBillingDetails?.phone
    ? {
        phoneNumber: stripeBillingDetails.phone,
      }
    : null;

  const billingAddress = {
    fullName: stripeBillingDetails?.name,
    emailAddress: stripeBillingDetails?.email,
    addressLine1: stripeBillingDetails?.address?.line1,
    addressLine2: stripeBillingDetails?.address?.line2,
    city: stripeBillingDetails?.address?.city,
    stateProvinceRegion: stripeBillingDetails?.address?.state,
    country: stripeBillingDetails?.address?.country,
    postalCode: stripeBillingDetails?.address?.postal_code,
    phonePrimary,
  } as Address;

  const displayAttributes = {
    creditCardType: stripeCard.brand.toUpperCase(),
    creditCardNumber: maskCardNumber(stripeCard.last4),
    creditCardExpDateMonth: padStart(`${stripeCard.exp_month}`, 2, '0'),
    creditCardExpDateYear: `${stripeCard.exp_year}`,
  };

  const paymentName = `${capitalize(
    confirmationToken.payment_method_preview.card.wallet
  )} | ${displayAttributes.creditCardType} | ${
    displayAttributes.creditCardNumber
  } ${displayAttributes.creditCardExpDateMonth}/${
    displayAttributes.creditCardExpDateYear
  }`;
  let paymentType;

  if (event.expressPaymentType === 'apple_pay') {
    paymentType = DefaultPaymentType.APPLE_PAY;
  } else if (event.expressPaymentType === 'google_pay') {
    paymentType = DefaultPaymentType.GOOGLE_PAY;
  }

  return {
    owningUserName: stripeBillingDetails.name,
    owningUserEmailAddress: stripeBillingDetails.email,
    name: paymentName,
    type: paymentType,
    gatewayType: StripePaymentForm.TYPE,
    amount: paymentAmount,
    subtotal: cart.cartPricing.subtotal,
    adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
    fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
    taxTotal: cart.cartPricing.totalTax,
    isSingleUsePaymentMethod: true,
    billingAddress,
    paymentMethodProperties,
  };
};

const useUpdateContactInfoOrGenerateGuestToken = setError => {
  const { isAuthenticated } = useAuth();
  const { cart, setCart, guestToken, setGuestToken } = useCartContext();
  const { csrAnonymous } = useCsrContext();

  const checkoutClient = useCheckoutClient();
  const getCustomerToken = useGetCustomerAccessToken();
  const preview = usePreviewOptions();

  return async (emailAddress?: string): Promise<Cart> => {
    let newCart = cart;
    try {
      if (emailAddress && (guestToken || (isAuthenticated && !csrAnonymous))) {
        const accessToken = await getCustomerToken();
        newCart = await checkoutClient.updateContactInfoInCart(
          cart.id,
          { emailAddress },
          {
            accessToken,
            guestToken: guestToken?.tokenString,
            preview,
            version: cart.version,
          }
        );
      } else if (!guestToken) {
        const accessToken = await getCustomerToken();

        const options = {
          accessToken,
          guestToken: guestToken?.tokenString,
          preview,
          version: cart.version,
        } as CheckoutClientCallOptions;

        let response: GuestTokenResponse;

        if (emailAddress) {
          response = await checkoutClient.checkoutAsGuest(
            cart.id,
            { emailAddress },
            options
          );
        } else {
          response = await checkoutClient.generateGuestToken(cart.id, options);
        }

        newCart = response.cart;

        setGuestToken(response.token);
      }

      if (newCart) {
        setCart(newCart);
      }
    } catch (err) {
      console.error(
        'There was an error updating the checkout information',
        err
      );
      setError(err);
      return Promise.reject(err);
    }

    return newCart;
  };
};

const useBuildShippingOptions = () => {
  const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();

  return (fulfillmentOptions: Array<PricedFulfillmentOption>, cart: Cart) => {
    const currentPricedFulfillmentOption =
      getShipGroupPriceFulfillmentOption(cart);

    return reduce(
      fulfillmentOptions,
      (result, fulfillmentOption) => {
        const shippingOption = {
          id: fulfillmentOption.description,
          displayName: fulfillmentOption.description,
          deliveryEstimate: {
            minimum: {
              unit: 'day',
              value: fulfillmentOption.estimatedMinDaysToFulfill,
            },
            maximum: {
              unit: 'day',
              value: fulfillmentOption.estimatedMaxDaysToFulfill,
            },
          },
          amount: formatAmountInMinorUnits(fulfillmentOption.price),
        };

        if (shippingOption.id === currentPricedFulfillmentOption?.description) {
          // add the selected fulfillment option to the head
          // The first shipping option listed appears in the browser payment interface as the default option
          result = [shippingOption, ...result];
        } else {
          result.push(shippingOption);
        }

        return result;
      },
      []
    );
  };
};

const buildFulfillmentAddress = (
  stripeShippingAddress: ExpressCheckoutPartialAddress
): FulfillmentAddress => {
  return {
    address1: undefined,
    address2: undefined,
    city: stripeShippingAddress.city,
    region: stripeShippingAddress.state,
    country: stripeShippingAddress.country,
    postalCode: stripeShippingAddress.postal_code,
  };
};

const buildShippingAddress = (
  stripeShippingAddress: ShippingAddress,
  stripeBillingDetails: BillingDetails
): Address => {
  return {
    fullName: stripeBillingDetails.name,
    addressLine1: stripeShippingAddress.address.line1,
    addressLine2: stripeShippingAddress.address.line2
      ? stripeShippingAddress.address.line2
      : undefined,
    city: stripeShippingAddress.address.city,
    stateProvinceRegion: stripeShippingAddress.address.state,
    country: stripeShippingAddress.address.country,
    postalCode: stripeShippingAddress.address.postal_code,
    phonePrimary: {
      phoneNumber: stripeBillingDetails.phone,
    },
  };
};

Preparing a PaymentTransactionServices Payment

To execute the Stripe transactions for ApplePay or GooglePay via PaymentTransactionServices, we must first create a Payment in PaymentTransactionServices.

Coming out of the Stripe Elements interaction, we expect to have a Stripe confirmation token, whose id & details should be provided in the following way:

const buildPaymentRequest = (
  event: StripeExpressCheckoutElementConfirmEvent,
  paymentAmount: MonetaryAmount,
  cart: Cart,
  confirmationToken: ConfirmationToken
): PaymentRequest => {
  const paymentMethodProperties = {
    CONFIRMATION_TOKEN_ID: confirmationToken.id,
  };

  const stripeBillingDetails = event.billingDetails;

  const stripeCard: PaymentMethod.Card =
    confirmationToken.payment_method_preview.card;

  const phonePrimary: Phone = stripeBillingDetails?.phone
    ? {
        phoneNumber: stripeBillingDetails.phone,
      }
    : null;

  const billingAddress = {
    fullName: stripeBillingDetails?.name,
    emailAddress: stripeBillingDetails?.email,
    addressLine1: stripeBillingDetails?.address?.line1,
    addressLine2: stripeBillingDetails?.address?.line2,
    city: stripeBillingDetails?.address?.city,
    stateProvinceRegion: stripeBillingDetails?.address?.state,
    country: stripeBillingDetails?.address?.country,
    postalCode: stripeBillingDetails?.address?.postal_code,
    phonePrimary,
  } as Address;

  const displayAttributes = {
    creditCardType: stripeCard.brand.toUpperCase(),
    creditCardNumber: maskCardNumber(stripeCard.last4),
    creditCardExpDateMonth: padStart(`${stripeCard.exp_month}`, 2, '0'),
    creditCardExpDateYear: `${stripeCard.exp_year}`,
  };

  const paymentName = `${capitalize(
    confirmationToken.payment_method_preview.card.wallet
  )} | ${displayAttributes.creditCardType} | ${
    displayAttributes.creditCardNumber
  } ${displayAttributes.creditCardExpDateMonth}/${
    displayAttributes.creditCardExpDateYear
  }`;
  let paymentType;

  if (event.expressPaymentType === 'apple_pay') {
    paymentType = DefaultPaymentType.APPLE_PAY;
  } else if (event.expressPaymentType === 'google_pay') {
    paymentType = DefaultPaymentType.GOOGLE_PAY;
  }

  return {
    owningUserName: stripeBillingDetails.name,
    owningUserEmailAddress: stripeBillingDetails.email,
    name: paymentName,
    type: paymentType,
    gatewayType: StripePaymentForm.TYPE,
    amount: paymentAmount,
    subtotal: cart.cartPricing.subtotal,
    adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
    fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
    taxTotal: cart.cartPricing.totalTax,
    isSingleUsePaymentMethod: true,
    billingAddress,
    paymentMethodProperties,
  };
};

Creating An ApplePay/GooglePay-based Saved Payment Method

When defining a PaymentTransactionServices Payment using an ApplePay/GooglePay-based payment method for a registered customer, the shouldSavePaymentForFutureUse property can be provided to produce a SavedPaymentMethod for the customer.

Testing Locally

To test the Apple or Google Pay integration follow the next steps:

  1. See the Prerequisites before starting the testing.

  2. Add the following configuration:

     broadleaf:
       tenant:
         urlresolver:
           application:
             port: '443'
             domain: 'eu.ngrok.io'
  3. Run your app locally as usual

  4. Execute ngrok http https://{yourAppUri}/ to build the tunnel to your local app. Replace yourAppUri with your app URI e.g myapp.localhost:8456. This will generate the domain e.g. https://c746-176-115-97-170.eu.ngrok.io (used in the steps below).

  5. In the Admin panel go to the Security → Authorization Servers, open your app configuration and click on Authorized Clients tab (/authorization-servers/{yourAppId}?form=authorizedClients). Add the Redirect Urls - https://c746-176-115-97-170.eu.ngrok.io/callback and https://c746-176-115-97-170.eu.ngrok.io/silent-callback.html to your Authorized Client.

  6. In the Admin panel go to the Tenant Management → Applications and open your application configuration from the list. In the Application Identifier field replace the value with eu.ngrok.io. This step is needed to resolve your application. See com.broadleafcommerce.tenant.web.endpoint.TenantResolverEndpoint.

  7. Verify your domain with Apple Pay. If you are using React to build your app, you can put the domain association file to the public folder in the root of your project.

If you made everything correctly, you should be able to open your app by https://c746-176-115-97-170.eu.ngrok.io URL.

Note
Also see how to add a payment via the Commerce SDK.