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 {Elements} from '@stripe/react-stripe-js';
import {loadStripe} from '@stripe/stripe-js';
const root = ReactDOM.createRoot(document.getElementById('root'));
const stripePromise = loadStripe('pk_test_');
root.render(
  <React.StrictMode>
    <Elements stripe={stripePromise}>
      <App />
    </Elements>
  </React.StrictMode>
);
import React, { useState, useMemo, useEffect, FC } from 'react';

import {
  PaymentRequestButtonElement,
  useStripe,
} from '@stripe/react-stripe-js';
import {
  PaymentMethod as StripePaymentMethod,
  PaymentRequest as StripePaymentRequest,
  PaymentRequestPaymentMethodEvent,
  PaymentRequestOptions,
  StripePaymentRequestButtonElementOptions,
} from '@stripe/stripe-js';

import { padStart, capitalize } from 'lodash';

import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { maskCardNumber } from '@broadleaf/payment-js';

import {
  Address,
  PaymentRequest,
  PaymentSummary,
  Phone,
} from '@broadleaf/commerce-cart';

import type { MonetaryAmount } from '@broadleaf/commerce-browse';

const StripeWallet: FC = () => {
  // resolve cart
  const { cart, setCart } ...;

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

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

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

  // Create the Stripe PaymentRequest
  const stripePaymentRequest = useStripePaymentRequest({
    options: {
      country: 'US', // The two-letter country code of your Stripe account (e.g., US).
      currency: cart.cartPricing.total.currency.toLowerCase(), // Three character currency code (e.g., usd).
      total: {
        label: 'Payment total',
        amount: formatAmountInMinorUnits(cart.cartPricing.total), // The amount in the currency's subunit (e.g. cents, yen, etc.)
      },
      requestPayerName: true, // request the payer name
      requestPayerEmail: true, // request the payer email
    },
    // Stripe.js automatically creates a PaymentMethod after the customer is done interacting with the browser’s payment interface.
    // To access the created PaymentMethod, listen for this event.
    onPaymentMethod: event => createPayment(cart.cartPricing.total, event),
  });
  const options = useOptions(stripePaymentRequest);

  if (!stripePaymentRequest) {
    return null;
  }

  return (
    <div>
      <PaymentRequestButtonElement
        className="stripe-wallet-button"
        options={options}
      />
      {error && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          Error ...
        </strong>
      )}
    </div>
  );
};

const useOptions = (
  stripePaymentRequest: StripePaymentRequest
): StripePaymentRequestButtonElementOptions => {
  return useMemo(
    () => ({
      paymentRequest: stripePaymentRequest,
    }),
    [stripePaymentRequest]
  );
};

type StripePaymentRequestProps = {
  options: PaymentRequestOptions;
  onPaymentMethod: (event: PaymentRequestPaymentMethodEvent) => void;
};

const useStripePaymentRequest = ({
  options,
  onPaymentMethod,
}: StripePaymentRequestProps) => {
  const stripe = useStripe();
  const [stripePaymentRequest, setStripePaymentRequest] =
    useState<StripePaymentRequest>(null);
  const [canMakePayment, setCanMakePayment] = useState(false);

  useEffect(() => {
    if (stripe && stripePaymentRequest === null) {
      // create the Stripe PaymentRequest
      const pr = stripe.paymentRequest(options);
      setStripePaymentRequest(pr);
    }
  }, [stripe, options, stripePaymentRequest]);

  useEffect(() => {
    let subscribed = true;
    if (stripePaymentRequest) {
      // check if the ApplePay or GooglePay can be used
      stripePaymentRequest.canMakePayment().then(res => {
        if ((res.applePay || res.googlePay) && subscribed) {
          setCanMakePayment(true);
        }
      });
    }

    return () => {
      subscribed = false;
    };
  }, [stripePaymentRequest]);

  useEffect(() => {
    if (stripePaymentRequest) {
      stripePaymentRequest.on('paymentmethod', onPaymentMethod);
    }
    return () => {
      if (stripePaymentRequest) {
        stripePaymentRequest.off('paymentmethod');
      }
    };
  }, [stripePaymentRequest, onPaymentMethod]);

  return canMakePayment ? stripePaymentRequest : null;
};

const useCreatePayment = (cart, setCart, setError) => {
  // load the existing payments
  const { payments }  = ...;
  // the PaymentClient from '@broadleaf/commerce-cart'
  const paymentClient = usePaymentClient();
  // the AuthState from '@broadleaf/payment-js'
  const authState = ...;

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

  const createPayment = async (
    amount: MonetaryAmount,
    stripeEvent: PaymentRequestPaymentMethodEvent
  ) => {
    const { complete, paymentMethod, payerName, payerEmail, walletName } =
      stripeEvent;
    const paymentMethodProperties = {
      PAYMENT_METHOD_ID: paymentMethod.id,
      CUSTOMER_ID: paymentMethod.customer,
    };

    const stripeBillingDetails: StripePaymentMethod.BillingDetails =
      paymentMethod.billing_details;
    const stripeCard: StripePaymentMethod.Card = paymentMethod.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(walletName)} | ${
      displayAttributes.creditCardType
    } | ${displayAttributes.creditCardNumber} ${
      displayAttributes.creditCardExpDateMonth
    }/${displayAttributes.creditCardExpDateYear}`;

    let paymentType;

    if (walletName === 'applePay') {
      paymentType = 'APPLE_PAY';
    } else if (walletName === 'googlePay') {
      paymentType = 'GOOGLE_PAY';
    }

    // create the request to create the Payment
    const paymentRequest = {
      owningUserName: payerName,
      owningUserEmailAddress: payerEmail,
      name: paymentName,
      type: paymentType,
      gatewayType: 'STRIPE',
      amount,
      subtotal: cart.cartPricing.subtotal,
      adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
      fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
      taxTotal: cart.cartPricing.totalTax,
      isSingleUsePaymentMethod: true,
      billingAddress,
      paymentMethodProperties,
      displayAttributes,
    } as PaymentRequest;

    try {
      // create the payment in PaymentTransactionServices
      const paymentSummary = await handleSubmitPaymentInfo(paymentRequest);

      if (paymentSummary) {
        complete('success');
      } else {
        complete('fail');
      }
    } catch (err) {
      complete('fail');
      console.error('There was an error adding payment information', err);
      setError(err);
    } finally {
      ...
    }
  };

  return { createPayment };
};

import { IntlFormatters, useIntl } from 'react-intl';

const useFormatNumber = (): IntlFormatters['formatNumber'] => {
  const intl = useIntl();
  return intl.formatNumber;
};

/**
 * Convert to minor part a MonetaryAmount instance.
 *
 * 'USD 2.35' will return 235, 'USD 2' will return 200.
 */
const useFormatAmountInMinorUnits = (): ((
  amount: MonetaryAmount
) => number) => {
  const formatNumber = useFormatNumber();
  return (amount: MonetaryAmount): number => {
    let formatted = formatNumber(amount.amount, {
      style: 'currency',
      currency: amount.currency,
    });
    // get rid of currency symbols
    formatted = formatted.replace(/[^\d.]/g, '');
    return Number(`${formatted}`.replace(/\D/g, ''));
  };
};

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, useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { capitalize, find, isEmpty, map, padStart, reduce } from 'lodash';

import {
  PaymentRequest as StripePaymentRequest,
  PaymentRequestShippingAddress,
  PaymentRequestShippingAddressEvent,
  PaymentRequestShippingOptionEvent,
} from '@stripe/stripe-js/types/stripe-js/payment-request';
import {
  PaymentMethod as StripePaymentMethod,
  PaymentRequestOptions,
  PaymentRequestPaymentMethodEvent,
  StripePaymentRequestButtonElementOptions,
} from '@stripe/stripe-js';
import { PaymentRequestButtonElement, useStripe } from '@stripe/react-stripe-js';

import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import { useFormatAmountInMinorUnits, useHandleSubmitCart, useUpdateFulfillmentGroup } from '@app/checkout/hooks';
import { MonetaryAmount } from '@broadleaf/commerce-browse';
import { usePaymentAuthState } from '@app/common/hooks';
import {
  Address,
  Cart,
  CheckoutClientCallOptions,
  DefaultFulfillmentType,
  FulfillmentAddress,
  GuestTokenResponse,
  PaymentRequest,
  PaymentSummary,
  Phone,
  PricedFulfillmentOption,
  UpdateFulfillmentGroupRequest,
  CartFulfillmentResponse,
  UpdateFulfillmentGroupAddressRequest,
} from '@broadleaf/commerce-cart';
import { useCsrContext } from '@app/csr/context';
import { areAddressesEquivalent, maskCardNumber } from '@broadleaf/payment-js';

import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { useCheckoutClient, usePaymentClient, usePreviewOptions, useCartClient } from '@app/common/contexts';
import { useFetchFulfillmentOptionsForCartItems, useSelectFulfillmentOption } from '@app/cart/hooks';

import { useGetCustomerAccessToken } from '@app/auth/hooks';

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

export const StripeWalletExpressCheckout: FC<Props> = ({
  className,
  setCanUseExpressCheckout,
}) => {
  const cartState = useCartContext();
  const { resolving: resolvingCart, cart } = cartState;

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

  const onShippingAddressChange = useOnShippingAddressChange(setError);
  const onShippingOptionChange = useOnShippingOptionChange(setError);
  const onPaymentMethod = useOnPaymentMethod(setError);

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

  const fulfillmentGroup = cart?.fulfillmentGroups[0];
  const fulfillmentType = fulfillmentGroup?.type;

  const isVirtualFulfillment =
    fulfillmentType === DefaultFulfillmentType.VIRTUAL;

  // Create the Stripe PaymentRequest
  const stripePaymentRequest = useStripePaymentRequest({
    options: {
      country: 'US', // The two-letter country code of your Stripe account (e.g., US).
      currency: cart.cartPricing.total.currency.toLowerCase(),
      total: {
        label: 'Payment Total',
        amount: formatAmountInMinorUnits(cart.cartPricing.total), // The amount in the currency's subunit (e.g. cents, yen, etc.)
        pending: true, //If this amount can be changed later (for example, after you have calcluated shipping costs).
      },
      requestPayerName: true, // request the payer name
      requestPayerEmail: true, // request the payer email
      requestShipping: !isVirtualFulfillment, // Collect shipping address
    },
    // Stripe.js automatically creates a PaymentMethod after the customer is done interacting with the browser’s payment interface.
    // To access the created PaymentMethod, listen for this event.
    onPaymentMethod,
    // The shippingaddresschange event is emitted from a PaymentRequest whenever the customer selects a new address in the browser's payment interface
    onShippingAddressChange,
    // The shippingoptionchange event is emitted from a PaymentRequest whenever the customer selects a new shipping option in the browser's payment interface.
    onShippingOptionChange,
    setCanUseExpressCheckout,
  });
  const options = useStripePaymentRequestButtonElementOptions(stripePaymentRequest);

  const handleStartCheckout = useHandleStartCheckout(stripePaymentRequest);

  if (resolvingCart || !cart || !stripePaymentRequest) {
    return null;
  }

  return (
    <div className={classNames(className, 'stripe-express-checkout')}>
      <PaymentRequestButtonElement
        options={options}
        onClick={handleStartCheckout}
      />
      {error && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          Error ...
        </strong>
      )}
    </div>
  );
};

const useStripePaymentRequestButtonElementOptions = (
  stripePaymentRequest: StripePaymentRequest
): StripePaymentRequestButtonElementOptions => {
  return useMemo(
    () => ({
      paymentRequest: stripePaymentRequest,
      style: {
        paymentRequestButton: {
       // Preferred button type to display. Available types, by wallet:
       // Google Pay: default, buy, or donate.
       // Apple Pay: default, book, buy, donate, check-out, subscribe, reload, add-money, top-up, order, rent, support, contribute, tip
       // When a wallet does not support the provided value, default is used as a fallback.
          type: 'check-out',
        },
      },
    }),
    [stripePaymentRequest]
  );
};

const useHandleStartCheckout = (stripePaymentRequest: StripePaymentRequest) => {
  const { cart } = useCartContext();

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

  return () => {
    const displayItems = 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,
        label,
      };
    });

    const paymentAmount = cart.cartPricing.total;

    stripePaymentRequest.update({
      currency: paymentAmount.currency.toLowerCase(),
      total: {
        label: 'Payment Total',
        amount: formatAmountInMinorUnits(paymentAmount),
        pending: true,
      },
      displayItems,
    });
  };
};

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

   const { updateFulfillmentGroupAddress } = useUpdateFulfillmentGroupAddress();

  const buildShippingOptions = useBuildShippingOptions();

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken(setError);

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

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

    try {
      newCart = await updateContactInfoOrGenerateGuestToken();

      const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;

      const fulfillmentAddress = buildFulfillmentAddress(event.shippingAddress);
      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
        );

        event.updateWith({
          status: 'success',
          total: {
            label: 'Payment Total',
            amount: formatAmountInMinorUnits(newCart.cartPricing.total),
            pending: true,
          },
          shippingOptions,
        });
      } else {
        event.updateWith({
          status: 'fail',
        });
      }
    } catch (err) {
      event.updateWith({
        status: 'fail',
      });

      setError(err);
    }
  };
};

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

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

  const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems(
    isEmpty(cart?.fulfillmentGroups[0]?.address)
  );

  const { selectFulfillmentOption } = useSelectFulfillmentOption();

  const buildShippingOptions = useBuildShippingOptions();

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

    try {
      const stripeShippingOption = event.shippingOption;

      const selectedOption = find(fulfillmentOptions, [
        'description',
        stripeShippingOption.id,
      ]);

      const response = await selectFulfillmentOption(selectedOption);

      newCart = response?.cart;

      if (newCart) {
        setCart(newCart);

        const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;

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

        const shippingOptions = buildShippingOptions(
          fulfillmentOptions,
          newCart
        );

        event.updateWith({
          status: 'success',
          total: {
            label: 'Payment Total',
            amount: formatAmountInMinorUnits(newCart.cartPricing.total),
            pending: true,
          },
          shippingOptions,
        });
      } else {
        event.updateWith({
          status: 'fail',
        });
      }
    } catch (err) {
      event.updateWith({
        status: 'fail',
      });
      setError(err);
    }
  };
};

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

  const { payments } = usePaymentsContext();

  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 updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken(setError);

  const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();

  return async (event: PaymentRequestPaymentMethodEvent) => {
    let newCart = cart;

    let paymentSummary: PaymentSummary;

    const fulfillmentGroup = cart?.fulfillmentGroups[0];
    const fulfillmentType = fulfillmentGroup?.type;

    const isVirtualFulfillment =
      fulfillmentType === DefaultFulfillmentType.VIRTUAL;

    try {
      newCart = await updateContactInfoOrGenerateGuestToken(event.payerEmail);

      if (!isVirtualFulfillment) {
        const shipGroupReferenceNumber = newCart?.fulfillmentGroups[0]?.referenceNumber;
        const shippingAddress = buildShippingAddress(event.shippingAddress);

        const currentShippingAddress = newCart?.fulfillmentGroups[0]?.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.complete('fail');
            setError(true);
            return;
          }
        }
      }

      const paymentRequest = buildPaymentRequest(
        event,
        newCart?.cartPricing.total,
        newCart
      );

      paymentSummary = await handleSubmitPaymentInfo(
        paymentRequest
      );

      if (paymentSummary) {
        await submitCart();
        event.complete('success');
      } else {
        event.complete('fail');
      }
    } catch (err) {
      setError(err);
      event.complete('fail');
    }
  };
};

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

  return (fulfillmentOptions: Array<PricedFulfillmentOption>, cart: Cart) => {
    const currentPricedFulfillmentOption = cart?.fulfillmentGroups[0]?.pricedFulfillmentOption;

    return reduce(
      fulfillmentOptions,
      (result, fulfillmentOption) => {
        const shippingOption = {
          id: fulfillmentOption.description,
          label: fulfillmentOption.description,
          detail: fulfillmentOption.description,
          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 buildPaymentRequest = (
  event: PaymentRequestPaymentMethodEvent,
  paymentAmount: MonetaryAmount,
  cart: Cart
): PaymentRequest => {
  const paymentMethodProperties = {
    PAYMENT_METHOD_ID: event.paymentMethod.id,
    CUSTOMER_ID: event.paymentMethod.customer,
  };

  const stripeBillingDetails: StripePaymentMethod.BillingDetails =
    event.paymentMethod.billing_details;
  const stripeCard: StripePaymentMethod.Card = event.paymentMethod.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(event.walletName)} | ${
    displayAttributes.creditCardType
  } | ${displayAttributes.creditCardNumber} ${
    displayAttributes.creditCardExpDateMonth
  }/${displayAttributes.creditCardExpDateYear}`;

  let paymentType;

  if (event.walletName === 'applePay') {
    paymentType = 'APPLE_PAY';
  } else if (event.walletName === 'googlePay') {
    paymentType = 'GOOGLE_PAY';
  }

  return {
    owningUserName: event.payerName,
    owningUserEmailAddress: event.payerEmail,
    name: paymentName,
    type: paymentType,
    gatewayType: 'STRIPE',
    amount: paymentAmount,
    subtotal: cart.cartPricing.subtotal,
    adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
    fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
    taxTotal: cart.cartPricing.totalTax,
    isSingleUsePaymentMethod: true,
    billingAddress,
    paymentMethodProperties,
    displayAttributes,
  };
};

const useUpdateContactInfoOrGenerateGuestToken = setError => {
  const { isAuthenticated, customerEmail } = usePaymentAuthState();
  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;

    emailAddress = emailAddress || cart?.emailAddress || customerEmail;

    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) {
      setError(err);
      return Promise.reject(err);
    }

    return newCart;
  };
};

const useUpdateFulfillmentGroupAddress = () => {
  const cartClient = useCartClient();
  const { guestToken } = useCartContext();

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

  const updateFulfillmentGroupAddress = useCallback(
    async (
      fulfillmentAddress: FulfillmentAddress,
      cart: Cart,
      fulfillmentGroupReference: string
    ): Promise<CartFulfillmentResponse> => {
      try {
        const request = {
          address: fulfillmentAddress,
          fulfillmentGroupReference,
        } as UpdateFulfillmentGroupAddressRequest;
        const accessToken = await getCustomerToken();
        const response = await cartClient.updateFulfillmentGroupAddress(
          cart.id,
          request,
          {
            accessToken,
            guestToken: guestToken?.tokenString,
            version: cart.version,
          }
        );

        setError(null);
        return response;
      } catch (error) {
        setError(error.response?.data);
      }
    },
    [cartClient, guestToken]
  );

  return { updateFulfillmentGroupAddress, error };
};

type StripePaymentRequestProps = {
  options: PaymentRequestOptions;
  onPaymentMethod: (event: PaymentRequestPaymentMethodEvent) => void;
  onShippingAddressChange: (event: PaymentRequestShippingAddressEvent) => void;
  onShippingOptionChange: (event: PaymentRequestShippingOptionEvent) => void;
  setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void;
};

const useStripePaymentRequest = ({
  options,
  onPaymentMethod,
  onShippingAddressChange,
  onShippingOptionChange,
  setCanUseExpressCheckout,
}: StripePaymentRequestProps) => {
  const stripe = useStripe();
  const [stripePaymentRequest, setStripePaymentRequest] =
    useState<StripePaymentRequest>(null);
  const [canMakePayment, setCanMakePayment] = useState(false);

  useEffect(() => {
    if (stripe && stripePaymentRequest === null) {
      const pr = stripe.paymentRequest(options);
      setStripePaymentRequest(pr);
    } else if (!stripe) {
      setCanUseExpressCheckout(false);
    }
  }, [stripe, options, stripePaymentRequest, setCanUseExpressCheckout]);

  useEffect(() => {
    let subscribed = true;
    if (stripePaymentRequest) {
      stripePaymentRequest.canMakePayment().then(res => {
        if (subscribed) {
          if (res.applePay || res.googlePay) {
            setCanUseExpressCheckout(true);
            setCanMakePayment(true);
          } else {
            setCanUseExpressCheckout(false);
          }
        }
      });
    }

    return () => {
      subscribed = false;
    };
  }, [stripePaymentRequest, setCanUseExpressCheckout]);

  useEffect(() => {
    if (stripePaymentRequest) {
      stripePaymentRequest.on('paymentmethod', onPaymentMethod);
      stripePaymentRequest.on('shippingaddresschange', onShippingAddressChange);
      stripePaymentRequest.on('shippingoptionchange', onShippingOptionChange);
    }
    return () => {
      if (stripePaymentRequest) {
        stripePaymentRequest.off('paymentmethod');
        stripePaymentRequest.off('shippingaddresschange');
        stripePaymentRequest.off('shippingoptionchange');
      }
    };
  }, [
    stripePaymentRequest,
    onPaymentMethod,
    onShippingAddressChange,
    onShippingOptionChange,
  ]);

  return canMakePayment ? stripePaymentRequest : null;
};

const buildFulfillmentAddress = (
  stripeShippingAddress: PaymentRequestShippingAddress
): FulfillmentAddress => {
  return {
    address1: stripeShippingAddress.addressLine[0],
    address2:
      stripeShippingAddress.addressLine.length > 1
        ? stripeShippingAddress.addressLine[1]
        : undefined,
    city: stripeShippingAddress.city,
    region: stripeShippingAddress.region,
    country: stripeShippingAddress.country,
    postalCode: stripeShippingAddress.postalCode,
  };
};

const buildShippingAddress = (
  stripeShippingAddress: PaymentRequestShippingAddress,
  emailAddress?: string
): Address => {
  return {
    emailAddress,
    fullName: stripeShippingAddress.recipient,
    addressLine1: stripeShippingAddress.addressLine[0],
    addressLine2:
      stripeShippingAddress.addressLine.length > 1
        ? stripeShippingAddress.addressLine[1]
        : undefined,
    city: stripeShippingAddress.city,
    stateProvinceRegion: stripeShippingAddress.region,
    country: stripeShippingAddress.country,
    postalCode: stripeShippingAddress.postalCode,
    phonePrimary: {
      phoneNumber: stripeShippingAddress.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 payment method, whose id & details should be provided in the following way:

{
  ...
  billingAddress: {
    firstName: paymentMethod.billing_details.name,
    fullName: paymentMethod.billing_details.name,
    emailAddress: paymentMethod.billing_details.email,
    addressLine1: paymentMethod.billing_details.line1,
    city: paymentMethod.billing_details.city,
    country: paymentMethod.billing_details.country,
    postalCode: paymentMethod.billing_details.address.postal_code,
    phonePrimary: {
      countryCode: paymentMethod.billing_details.countryCode,
      phoneNumber: paymentMethod.billing_details.phone
    }
  },
  name: 'Payment Name',
  displayAttributes: {
  	creditCardType: paymentMethod.card.brand.toUpperCase(),
  	creditCardNumber: `****${paymentMethod.card.last4}`,
  	creditCardExpDateMonth: `${paymentMethod.card.exp_month}`,
  	creditCardExpDateYear: `${paymentMethod.card.exp_year}`
  },
  gatewayType: "STRIPE",
  type: paymentMethod.card.wallet.type,
  paymentMethodProperties: {
    "PAYMENT_METHOD_ID": paymentMethod.id,
    "CUSTOMER_ID": paymentMethod.customer
  },
  isSingleUsePaymentMethod: true,
  ...
}

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.