Broadleaf Microservices
  • v1.0.0-latest-prod

Digital Wallets (Apple Pay, Google Pay, & PayPal)

Digital Wallets in the Checkout Flow

In your checkout flow, the Adyen Drop-in UI can be used to easily gather the customer’s wallet-based payment method. From a technical perspective, the Apple Pay & Google Pay flows largely reflect that of typical credit card interactions, where once the payment method data is gathered, checkout is submitted & we attempt the checkout transaction. In the case of Apple Pay, there will never be additional actions that need to be handled, but in the case of Google Pay, 3DS verification may be required.

As for PayPal, the overall flow reflects a typical hosted payment page interaction. (Note: this is the same pattern that’s described in the Adyen 3DS document.) When the PayPal button is clicked in the Drop-in UI, trigger a checkout attempt & execute a payment transaction. We expect this response to prompt us with an additional action. From there, we provide the action payload to the Adyen Drop-in UI, which will direct the user to PayPal’s UI. Once this interaction is successfully completed, a transaction is executed. From there, we use both webhooks & the CartOps callback endpoint (ExternalPaymentTransactionCallbackEndpoint) to gather transaction results, & attempt to finalize the cart.

Frontend Integration

The following code snippet expands upon the Drop-in integration described here, & adding the pieces required to support Apple Pay, Google Pay, & PayPal.

import { FC, useState, useEffect } from 'react';
import { isNil, join } from 'lodash';

import AdyenCheckout from '@adyen/adyen-web';
import DropinElement from '@adyen/adyen-web/dist/types/components/Dropin';
import {
  ShopperDetails,
  PaymentMethodOptions,
} from '@adyen/adyen-web/dist/types/types';
import UIElement from '@adyen/adyen-web/dist/types/components/UIElement';
import '@adyen/adyen-web/dist/adyen.css';

import {
  DefaultPaymentType,
  PaymentMethodOptionResponse,
  UpdatePaymentRequest,
} from '@broadleaf/commerce-cart';

import { useFormatMessage, usePaymentAuthState } from '@app/common/hooks';

import { AdyenConfig } from '@app/common/utils';

import {
  useAdyenPaymentServicesClient,
  usePaymentClient,
} from '@app/common/contexts';
import { useAdyenCreatePaymentSessionRequest } from '@broadleaf/adyen-payment-services-react';
import {
  CreatePaymentSessionRequest,
  CreatePaymentSessionResponse,
} from '@broadleaf/adyen-payment-services-api';

import {
  useFormatAmountInMinorUnits,
  useGetPaymentAmount,
  useGetPaymentCallbackUrl,
  useHandleMoneyAddition,
} from '@app/checkout/hooks';
import { useCartContext } from '@app/cart/contexts';

import {
  useGetLineItems,
  useHandleOnPaymentCompleted,
  useHandleSubmitAdyenPayment,
} from './adyen-hooks';

import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { Address } from '@broadleaf/commerce-customer';

import messages from '@app/checkout/messages';

type Props = {
  paymentOptions: Array<PaymentMethodOptionResponse>;
};

type AdyenPaymentFormType = FC<Props> & {
  TYPE: 'ADYEN';
};

export const AdyenPaymentForm: AdyenPaymentFormType = () => {
  const formatMessage = useFormatMessage();

  const { cart } = useCartContext();

  const authState = usePaymentAuthState();

  const adyenPaymentServicesClient = useAdyenPaymentServicesClient();

  const { createPaymentSession, error: createPaymentSessionError } =
    useAdyenCreatePaymentSessionRequest({
      adyenPaymentServicesClient,
      authState,
    });

  const [session, setSession] = useState<CreatePaymentSessionResponse>();
  const [dropinComponent, setDropinComponent] = useState<DropinElement>();

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

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

  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  const [sessionIsCreating, setSessionIsCreating] = useState<boolean>(false);

  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();
  const handleMoneyAddition = useHandleMoneyAddition();

  const getLineItems = useGetLineItems();

  useEffect(() => {
    if (!session && cart && !sessionIsCreating) {
      setSessionIsCreating(true);
      const amount = getPaymentAmount();
      const returnUrl = getPaymentCallbackUrl({
        gatewayType: AdyenPaymentForm.TYPE,
      });

      const countryCode = cart.fulfillmentGroups[0]?.address?.country;
      const lineItems = getLineItems(cart);
      const request = {
        amount,
        reference: cart.id,
        returnUrl,
        countryCode,
        //hardcode, backend will know what to do with that when customer logged in
        recurringProcessingModel: 'CardOnFile',
        storePaymentMethodMode: 'askForConsent',
        shopperEmail: authState.isAuthenticated
          ? authState.customerEmail
          : cart.emailAddress,
        lineItems: lineItems,
      } as CreatePaymentSessionRequest;

      createPaymentSession(request)
        .then(response => setSession(response))
        .finally(() => setSessionIsCreating(false));
    }
  }, [
    authState,
    cart,
    session,
    setSession,
    createPaymentSession,
    getPaymentAmount,
    getPaymentCallbackUrl,
    sessionIsCreating,
    setSessionIsCreating,
    formatAmountInMinorUnits,
    handleMoneyAddition,
    getLineItems,
  ]);

  const [error, setError] = useState();

  const handleOnSubmit = useHandleSubmitAdyenPayment({ setError });

  const handleOnPaymentCompleted = useHandleOnPaymentCompleted({
    setUIElement: setDropinComponent,
  });

  const [paymentData, setPaymentData] = useState();
  const [submitState, setSubmitState] = useState();

  const countryCode = cart?.fulfillmentGroups[0]?.address?.country || 'US';

  useEffect(() => {
    if (session && !dropinComponent && !sessionIsCreating) {
      const amount = getPaymentAmount();

      const adyenAmountInMinorUnits = {
        value: formatAmountInMinorUnits(amount),
        currency: amount.currency,
      };

      const configuration = {
        environment: 'test',
        clientKey: AdyenConfig.CLIENT_KEY,
        analytics: {
          enabled: false,
        },
        session: {
          id: session.id, // Unique identifier for the payment session.
          sessionData: session.sessionData, // The payment session data.
        },
        paymentMethodsConfiguration: {
          card: {
            hasHolderName: true,
            holderNameRequired: true,
            billingAddressRequired: true,
          },
          googlepay: {
            amount: adyenAmountInMinorUnits,
            countryCode: countryCode,
            //Set this to PRODUCTION when you're ready to accept live payments
            environment: 'TEST',
            billingAddressRequired: true,
            billingAddressParameters: {
              format: 'FULL' as google.payments.api.BillingAddressFormat,
            },
            onAuthorized: paymentData => {
              setPaymentData(paymentData);
            },
          },
          applepay: {
            amount: adyenAmountInMinorUnits,
            countryCode: countryCode,
            requiredBillingContactFields: [
              'name',
              'postalAddress',
            ] as ApplePayJS.ApplePayContactField[],
            onAuthorized: (resolve, reject, paymentData) => {
              setPaymentData(paymentData);
              resolve({
                status: ApplePaySession.STATUS_SUCCESS,
              } as ApplePayJS.ApplePayPaymentAuthorizationResult);
            },
          },
          paypal: {
            amount: adyenAmountInMinorUnits,
            blockPayPalCreditButton: true,
            blockPayPalPayLaterButton: true,
            blockPayPalVenmoButton: true,
            environment: 'test', // Change this to "live" when you're ready to accept live PayPal payments
            countryCode: countryCode,
            onShopperDetails: async (shopperDetails, paypalOrder, actions) => {
              if (paymentSummaryStore.getValue()) {
                const paymentUpdateRequest = {
                  paymentId: paymentSummaryStore.getValue().paymentId,
                  billingAddress: mapBillingAddress(shopperDetails),
                } as UpdatePaymentRequest;

                try {
                  const updatedPaymentSummary = await handleUpdatePaymentInfo(
                    paymentUpdateRequest,
                    paymentSummaryStore.getValue().version
                  );
                  paymentSummaryStore.setValue(updatedPaymentSummary);
                } catch (err) {
                  console.error('EXCEPTION FOR PAYMENT UPDATE:', err);
                }
              }

              actions.resolve();
            },
          } as PaymentMethodOptions<'paypal'>,
        },
        onSubmit: (state, dropin) => {
          const paymentType = PaymentTypeHelper.getPaymentType();

          if (
            paymentType === DefaultPaymentType.GOOGLE_PAY ||
            paymentType === DefaultPaymentType.APPLE_PAY
          ) {
            setSubmitState(state);
            setDropinComponent(dropin);
          } else {
            handleOnSubmit({
              state,
              paymentType: PaymentTypeHelper.getPaymentType(),
              component: dropin,
              sessionId: session.id,
            }).then(paymentSummary => {
              paymentSummaryStore.setValue(paymentSummary);
            });
          }
        },
        onPaymentCompleted: handleOnPaymentCompleted,
        onError: (error, component) => {
          console.error(error.name, error.message, error.stack);

          setError(error);
        },
      };

      AdyenCheckout(configuration).then(checkout => {
        const dComponent = checkout
          .create('dropin', {
            showStoredPaymentMethods: true,
            onSelect: (dropin: UIElement) => {
              if (dropin.type.toLowerCase() === 'card') {
                PaymentTypeHelper.setPaymentType(
                  DefaultPaymentType.CREDIT_CARD
                );
              } else if (dropin.type.toLowerCase() === 'googlepay') {
                PaymentTypeHelper.setPaymentType(DefaultPaymentType.GOOGLE_PAY);
              } else if (dropin.type.toLowerCase() === 'applepay') {
                PaymentTypeHelper.setPaymentType(DefaultPaymentType.APPLE_PAY);
              } else if (dropin.type.toLowerCase() === 'paypal') {
                PaymentTypeHelper.setPaymentType(DefaultPaymentType.PAYPAL);
              } else if (dropin.type.toLowerCase().includes('klarna')) {
                PaymentTypeHelper.setPaymentType('KLARNA');
              }
            },
          })
          .mount('#dropin-container');

        setDropinComponent(dComponent);
      });
    }

    return () => {
      if (dropinComponent) {
        dropinComponent.unmount();
        setDropinComponent(undefined);
      }
    };
    // eslint-disable-next-line
  }, [
    session,
    countryCode,
    dropinComponent,
    setDropinComponent,
    sessionIsCreating,
    handleOnSubmit,
    handleOnPaymentCompleted,
    getPaymentAmount,
    formatAmountInMinorUnits,
    PaymentTypeHelper,
  ]);

  useEffect(() => {
    // submit the Google/Apple Pay with addition payment data
    if (submitState && paymentData && dropinComponent) {
      const paymentType = PaymentTypeHelper.getPaymentType();

      handleOnSubmit({
        state: submitState,
        paymentData,
        paymentType,
        component: dropinComponent,
        sessionId: session.id,
      });
    }
    // eslint-disable-next-line
  }, [submitState, paymentData, dropinComponent, handleOnSubmit]);

  return (
    <div>
      <h2 className="py-4 text-xl font-medium">
        {formatMessage(messages.paymentMethod)}
      </h2>
      <div id="dropin-container"></div>
      {(createPaymentSessionError || error) && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {formatMessage(messages.genericError)}
        </strong>
      )}
    </div>
  );
};

const mapBillingAddress = (shopperDetails: ShopperDetails): Address => {
  const {
    houseNumberOrName,
    street,
    city,
    stateOrProvince,
    country,
    postalCode,
  } = shopperDetails.billingAddress;

  const fullName = getShopperFullName(shopperDetails);

  const addressLine1 =
    !isNil(houseNumberOrName) && houseNumberOrName !== 'N/A'
      ? join([houseNumberOrName, street], ', ')
      : street;

  return {
    fullName,
    addressLine1,
    city: city,
    stateProvinceRegion: stateOrProvince,
    country: country,
    postalCode: postalCode,
  };
};

const getShopperFullName = (shopperDetails: ShopperDetails): string => {
  const { shopperName, billingAddress } = shopperDetails;

  const firstName = shopperName?.firstName || billingAddress.firstName;
  const lastName = shopperName?.lastName || billingAddress.lastName;

  return join([firstName, lastName], ' ');
};

const createPaymentSummaryStore = () => {
  let value = null;
  return {
    setValue(newValue: unknown) {
      value = newValue;
    },
    getValue() {
      return value;
    },
  };
};
const paymentSummaryStore = createPaymentSummaryStore();

const createPaymentTypeHolder = () => {
  let paymentType = 'CREDIT_CARD';

  return {
    getPaymentType() {
      return paymentType;
    },
    setPaymentType(type: string) {
      paymentType = type;
    },
  };
};
const PaymentTypeHelper = createPaymentTypeHolder();

AdyenPaymentForm.TYPE = 'ADYEN';
Payment Method Configuration

In the Adyen Drop-in configuration’s paymentMethodsConfiguration section, each digital wallet must be configured.

const countryCode = cart.fulfillmentGroups[0]?.address?.country;

const configuration = {
  // ...  other required configuration
  paymentMethodsConfiguration: {
    ...
    applepay: {
      amount: adyenAmountInMinorUnits,
      countryCode: countryCode,
      requiredBillingContactFields: [ // (1)
        'name',
        'postalAddress',
      ] as ApplePayJS.ApplePayContactField[],
      onAuthorized: (resolve, reject, paymentData) => { // (2)
        setPaymentData(paymentData);
        resolve({
          status: ApplePaySession.STATUS_SUCCESS,
        } as ApplePayJS.ApplePayPaymentAuthorizationResult);
      },
    },
    googlepay: {
      amount: adyenAmountInMinorUnits,
      countryCode: countryCode,
      //Set this to PRODUCTION when you're ready to accept live payments
      environment: 'TEST',
      billingAddressRequired: true, // (3)
      billingAddressParameters: {
        format: 'FULL' as google.payments.api.BillingAddressFormat,
      },
      onAuthorized: paymentData => { // (4)
        setPaymentData(paymentData);
      },
    },
    paypal: {
      amount: adyenAmountInMinorUnits,
      blockPayPalCreditButton: true,
      blockPayPalPayLaterButton: true,
      blockPayPalVenmoButton: true,
      environment: 'test', // Change this to "live" when you're ready to accept live PayPal payments
      countryCode: countryCode,
      onShopperDetails: async (shopperDetails, paypalOrder, actions) => { // (5)
        if (paymentSummaryStore.getValue()) {
          const paymentUpdateRequest = {
            paymentId: paymentSummaryStore.getValue().paymentId,
            billingAddress: mapBillingAddress(shopperDetails),
          } as UpdatePaymentRequest;

          try {
            const updatedPaymentSummary = await handleUpdatePaymentInfo(
              paymentUpdateRequest,
              paymentSummaryStore.getValue().version
            );
            paymentSummaryStore.setValue(updatedPaymentSummary);
          } catch (err) {
            console.error('EXCEPTION FOR PAYMENT UPDATE:', err);
          }
        }

        actions.resolve();
      },
    } as PaymentMethodOptions<'paypal'>,
  }
};
  1. Request that the Apple Pay interaction provides the billing address related to the payment method

  2. Declares that the Apple Pay billing address is available

  3. Request that the Google Pay interaction provides the billing address related to the payment method

  4. Declares that the Google Pay billing address is available

  5. PayPal event handler for obtaining and recording the billing address

Additional Action Handling for Google Pay & PayPal

When receiving a next action payload related to a Google Pay or PayPal interaction, the frontend must provide the payload to the Adyen Drop-In UI to handle the action. Note: this is the same pattern that’s used for 3DS verification with credit cards.

The following snippet shows how this can be done in reaction to the checkout submission response (esp. see the usage of adyenComponent.handleAction(action);):

export const useHandleSubmitAdyenPayment = (
  props: HandleSubmitProps = {}
): HandleSubmitResponse => {
  const { setError = noop } = props;
  const cartState = useCartContext();
  const { cart } = cartState;
  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();
  const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
    authState,
    payments: undefined,
    ownerId: cart?.id,
    owningUserEmailAddress: cart.emailAddress,
    paymentClient,
    multiplePaymentsAllowed: false,
    rejectOnError: true,
  });

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

  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  const { error, onSubmit: submitCart } = useHandleSubmitCart();

  const [adyenComponent, setAdyenComponent] = useState<UIElement>();

  useEffect(() => {
    if (!adyenComponent) {
      return;
    }

    const errorType = get(error, 'failureType');
    if (
      errorType === 'PAYMENT_REQUIRES_3DS_VERIFICATION' ||
      errorType === 'PAYMENT_REQUIRES_EXTERNAL_INTERACTION' ||
      errorType === 'PAYMENT_REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION'
    ) {
      const errorDetails = find(
        get(error, 'paymentTransactionFailureDetails'),
        ({ failureType }) =>
          failureType === 'REQUIRES_3DS_VERIFICATION' ||
          failureType === 'REQUIRES_EXTERNAL_INTERACTION' ||
          failureType === 'REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION'
      );

      const action = get(errorDetails, 'nextAction.attributes');

      adyenComponent.handleAction(action);
    } else if (errorType && error) {
      setError(error);
    }
  }, [error, adyenComponent, setError]);

  const getLineItems = useGetLineItems();

  return useEventCallback(
    async ({
      state,
      paymentType,
      component,
      sessionId,
      paymentData,
    }: HandleSubmitParameters) => {
      setError(undefined);
      const data = get(state, 'data');

      setAdyenComponent(component);

      const billingAddress = getBillingAddress(state, paymentType, paymentData);

      // these are required to pass to the backend as part of the payment
      const returnUrl = getPaymentCallbackUrl({
        gatewayType: AdyenPaymentForm.TYPE,
      });

      const amount = getPaymentAmount(cart);
      const paymentRequest = {
        name: AdyenPaymentForm.TYPE,
        type: paymentType,
        gatewayType: AdyenPaymentForm.TYPE,
        amount,
        subtotal: cart.cartPricing.subtotal,
        adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
        fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
        taxTotal: cart.cartPricing.totalTax,
        isSingleUsePaymentMethod: true,
        shouldArchiveExistingPayments: true,
        paymentMethodProperties: {
          sessionId,
          returnUrl,
        },
        billingAddress,
      } as PaymentRequest;

      let paymentSummary;

      try {
        paymentSummary = await handleSubmitPaymentInfo(paymentRequest);

        if (paymentSummary) {
          pushGtmAddPayment(cart, paymentSummary);

          const countryCode = cart.fulfillmentGroups[0]?.address?.country;
          const lineItems = getLineItems(cart);

          const checkoutResponse = await submitCart({
            sensitivePaymentMethodData: [
              {
                paymentId: paymentSummary.paymentId,
                paymentMethodProperties: {
                  ADYEN_PAYMENT_DATA: {
                    ...data,
                    shopperEmail: authState.isAuthenticated
                      ? authState.customerEmail
                      : cart.emailAddress,
                    lineItems,
                    countryCode: countryCode,
                  },
                },
              },
            ],
          });

          return checkoutResponse?.paymentSummaries[0];
        }
      } catch (err) {
        console.error('There was an error adding payment information', err);
        setError(err);
      }
    },
    []
  );
};
Creating a PaymentTransactionServices Payment

When the Drop-in UI’s onSubmit action is triggered, we must first create a Payment in PaymentTransactionServices, before submitting checkout.

An example of the request to create a wallet-based Payment:

{
  type: '{APPLE_PAY, GOOGLE_PAY, or PAYPAL}',
  gatewayType: `ADYEN`,
  amount: {
      amount: 10,
      currency: 'USD',
  },
  isSingleUsePaymentMethod: true,
  paymentMethodProperties: {
    sessionId: '{Adyen session ID}',
    returnUrl: 'https://storefront.com/api/cart-operations/checkout/{cart_id}/payment-callback/ADYEN?tenantId={tenant_id}&applicationId={application_id}',
  },
  billingAddress: {...},
}
Note
  • This request assumes that you’ve already created an Adyen session.

  • Notice that the type of digital wallet is declared via the payment type property.

  • See how to add a payment via the Commerce SDK.

Express Checkout

These same digital wallets can be engaged in an express checkout context, where you gather the customer’s contact info, shipping address, fulfillment option, & billing data via the wallet UI, rather than in your checkout flow. Overall, Apple Pay & Google Pay have very similar interactions, whereas, PayPal has some unique quirks that lead to it having a different integration pattern. More details on this below.

Apple Pay & Google Pay Flows

The Apple Pay and Google Pay flows look like this: once a customer has added an item to their cart, they can be presented with either the Apple Pay or Google Pay. Clicking either of these actions prompts Apple or Google UIs to be shown to the user. Throughout this interaction, the Adyen Drop-in component’s event handlers are triggered to let us know that more information is available, at which point, we make various calls to Broadleaf services to update cart data (i.e. the same data that we would have obtained via a typical checkout). The final two events onSubmit and onAuthorize provide us with details about the payment method & communicate the customer’s intention to submit their checkout. This is the point where we create the PaymentTransactionServices Payment and submit checkout, both validating the cart data and executing the checkout payment transaction.

PayPal Flow

Express checkout with PayPal varies a bit from the Apple Pay and Google Pay flows, in that a few of the Drop-in event handlers are called at different points of the interaction. Most notably is that the onSubmit action is called immediately once the customer clicks the PayPal button (i.e. at the very beginning of the flow). The expectation here is that an Adyen payment is created to obtain a next action object which is used to prompt the Drop-in component to show the PayPal UI. We handle this interaction via a create payment request to PaymentTransactionServices, which also creates a Broadleaf payment representation, linking Adyen’s representation to ours.

From there, the typical Drop-in events are triggered to inform us of the customer’s contact info, shipping info, etc., and we make updates to the cart along the way.

For PayPal, the final action is onAdditionalDetails indicating the customer’s intention to submit their order. At this point, we submit checkout, both validating the cart data and executing the checkout payment transaction.

Note

Because of the unique split between the Adyen payment creation (where we declare the checkout transaction type) and the checkout submission (where we typically declare the checkout transaction type), we must keep the configuration of the transaction type aligned. In short, an Authorize transaction will be used by default. If you wish to use an AuthorizeAndCapture transaction, then…​

  1. When creating the PaymentTransactionServices payment as part of the onSubmit action, declare checkoutTransactionType = AUTHORIZE_AND_CAPTURE.

  2. Configure the checkout transaction type in CartOps to broadleaf.cartoperation.service.checkout.checkout-transaction-types.ADYEN.PAYPAL_EXPRESS=AUTHORIZE_AND_CAPTURE

Note
For express checkout interactions, make sure to declare APPLE_PAY_EXPRESS, GOOGLE_PAY_EXPRESS, or PAYPAL_EXPRESS as the PaymentTransactionServices Payment#type. This is especially important for PayPal!

Frontend Examples

Apple Pay Frontend Integration Example
import React, { FC, useEffect, useState } from 'react';
import { isEmpty, reduce, toString } from 'lodash';

import { useCartContext } from '@app/cart/contexts';
import {
  useGetPaymentAmount,
  useUpdateFulfillmentGroup,
} from '@app/checkout/hooks';
import { AdyenPaymentForm } from '@app/checkout/components';
import UIElement from '@adyen/adyen-web/dist/types/components/UIElement';
import Checkout from '@adyen/adyen-web/dist/types/core';
import { PaymentMethodOptions } from '@adyen/adyen-web/dist/types/types';
import {
  Address,
  Cart,
  DefaultPaymentType,
  FulfillmentAddress,
  PricedFulfillmentOption,
  UpdateFulfillmentGroupRequest,
} from '@broadleaf/commerce-cart';

import { getShipGroup, getShipGroupReferenceNumber } from '@app/cart/utils';
import { areAddressesEquivalent } from '@broadleaf/payment-js';
import { AdyenExpressCheckoutButtonContainer } from '@app/checkout/components/adyen-payment/express-checkout/adyen-express-checkout-button-container';

import {
  useHandleSubmitAdyenPayment,
  useUpdateContactInfoOrGenerateGuestToken,
  useUpdateFulfillmentAddress,
  useUpdateSelectedFulfillmentOption,
} from '../adyen-hooks';

type Props = {
  checkout: Checkout;
  setErrorMsg: (msg: string) => void;
};

export const ApplePayExpressCheckout: FC<Props> = ({
  checkout,
  setErrorMsg,
}) => {
  const [paymentData, setPaymentData] =
    useState<ApplePayJS.ApplePayPaymentAuthorizedEvent>();
  const [submitState, setSubmitState] = useState();
  const [adyenComponent, setAdyenComponent] = useState<UIElement>();

  const { configuration } = useComponentConfiguration({
    onSubmit: (state, component) => {
      setSubmitState(state);
      setAdyenComponent(component);
    },
    onAuthorized: (resolve, reject, paymentData) => {
      const shippingContact = paymentData.payment.shippingContact;

      if (isEmpty(shippingContact.administrativeArea)) {
        const update = {
          status: ApplePaySession.STATUS_INVALID_SHIPPING_POSTAL_ADDRESS,
          errors: [
            new ApplePayError(
              'shippingContactInvalid',
              'administrativeArea',
              'The state is required!'
            ),
          ],
        } as ApplePayJS.ApplePayPaymentAuthorizationResult;
        resolve(update);
        return;
      }

      setPaymentData(paymentData);
      resolve({
        status: ApplePaySession.STATUS_SUCCESS,
      } as ApplePayJS.ApplePayPaymentAuthorizationResult);
    },
  });

  const { setCart } = useCartContext();

  const handleSubmitAdyenPayment = useHandleSubmitAdyenPayment();

  const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();
  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken();

  useEffect(() => {
    if (submitState && paymentData && adyenComponent) {
      const handleSubmit = async () => {
        let newCart: Cart;

        try {
          const appleShippingAddress = paymentData.payment.shippingContact;
          const email = appleShippingAddress.emailAddress;

          newCart = await updateContactInfoOrGenerateGuestToken(email);

          const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
          const shippingAddress = buildShippingAddress(appleShippingAddress);

          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);
          }

          await handleSubmitAdyenPayment({
            state: submitState,
            paymentData,
            paymentType: DefaultPaymentType.APPLE_PAY_EXPRESS,
            component: adyenComponent,
          });
        } catch (err) {
          console.error('An error occurred while processing the request', err);
          setErrorMsg(toString(err));
        }
      };

      handleSubmit();
    }
  }, [
    submitState,
    paymentData,
    handleSubmitAdyenPayment,
    adyenComponent,
    updateContactInfoOrGenerateGuestToken,
    updateFulfillmentGroup,
    setCart,
    setErrorMsg,
  ]);

  if (isEmpty(configuration)) {
    return null;
  }

  return (
    <AdyenExpressCheckoutButtonContainer
      className="apple-pay-button"
      type="applepay"
      configuration={configuration}
      checkout={checkout}
    />
  );
};

const useComponentConfiguration = ({ onSubmit, onAuthorized }) => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const onShippingContactSelected = useOnShippingContactSelected();
  const onShippingMethodSelected = useOnShippingMethodSelected();

  const [configuration, setConfiguration] =
    useState<PaymentMethodOptions<'applepay'>>();

  useEffect(() => {
    const applePayComponentConfiguration = {
      isExpress: true,
      countryCode: 'US',
      buttonColor: 'black',
      buttonType: 'check-out',
      requiredBillingContactFields: ['name', 'postalAddress'],
      requiredShippingContactFields: [
        'postalAddress',
        'name',
        'phone',
        'email',
      ],
      onShippingContactSelected,
      onShippingMethodSelected,
      onSubmit,
      onAuthorized,
    } as PaymentMethodOptions<'applepay'>;

    setConfiguration(applePayComponentConfiguration);
    // eslint-disable-next-line
  }, [getPaymentAmount]);

  return {
    configuration,
  };
};

const useOnShippingContactSelected = () => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const updateFulfillmentAddress = useUpdateFulfillmentAddress({
    buildFulfillmentAddress,
  });

  const buildShippingMethods = useBuildShippingMethods();

  return async (
    resolve,
    reject,
    event: ApplePayJS.ApplePayShippingContactSelectedEvent
  ) => {
    const shippingContact = event.shippingContact;

    const paymentAmount = getPaymentAmount();

    const newTotal = {
      label: 'Total',
      amount: toString(paymentAmount.amount),
    } as ApplePayJS.ApplePayLineItem;

    if (isEmpty(shippingContact.administrativeArea)) {
      const update = {
        newTotal,
        errors: [
          new ApplePayError(
            'shippingContactInvalid',
            'administrativeArea',
            'The state is required!'
          ),
        ],
      } as ApplePayJS.ApplePayShippingContactUpdate;
      resolve(update);
      return;
    }

    const { fulfillmentOptions } = await updateFulfillmentAddress(
      shippingContact
    );

    const newShippingMethods = buildShippingMethods(fulfillmentOptions);

    const update = {
      newTotal,
      newShippingMethods,
    } as ApplePayJS.ApplePayShippingContactUpdate;

    resolve(update);
  };
};

const useOnShippingMethodSelected = () => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const updateSelectedFulfillmentOption = useUpdateSelectedFulfillmentOption();

  return async (
    resolve,
    reject,
    event: ApplePayJS.ApplePayShippingMethodSelectedEvent
  ) => {
    const updatedCart = await updateSelectedFulfillmentOption(
      event.shippingMethod.identifier
    );

    const paymentAmount = getPaymentAmount(updatedCart);

    const update = {
      newTotal: {
        label: 'Total',
        amount: toString(paymentAmount.amount),
      },
    } as ApplePayJS.ApplePayShippingMethodUpdate;

    resolve(update);
  };
};

const useBuildShippingMethods = () => {
  return (
    fulfillmentOptions: Array<PricedFulfillmentOption>
  ): ApplePayJS.ApplePayShippingMethod[] => {
    return reduce(
      fulfillmentOptions,
      (
        result: ApplePayJS.ApplePayShippingMethod[],
        fulfillmentOption: PricedFulfillmentOption
      ) => {
        result.push({
          identifier: fulfillmentOption.description,
          label: fulfillmentOption.description,
          detail: fulfillmentOption.description,
          amount: toString(fulfillmentOption.price.amount),
        });
        return result;
      },
      []
    );
  };
};

const buildFulfillmentAddress = (
  shippingContact: ApplePayJS.ApplePayPaymentContact
): FulfillmentAddress => {
  return {
    address1: shippingContact.addressLines?.[0],
    address2:
      shippingContact.addressLines && shippingContact.addressLines.length > 1
        ? shippingContact.addressLines?.[1]
        : undefined,
    city: shippingContact.locality,
    region: shippingContact.administrativeArea,
    country: shippingContact.countryCode,
    postalCode: shippingContact.postalCode,
  } as FulfillmentAddress;
};

const buildShippingAddress = (
  appleShippingAddress: ApplePayJS.ApplePayPaymentContact
): Address => {
  return {
    fullName: `${appleShippingAddress.givenName} ${appleShippingAddress.familyName}`,
    addressLine1: appleShippingAddress.addressLines[0],
    addressLine2:
      appleShippingAddress.addressLines.length > 1
        ? appleShippingAddress.addressLines[1]
        : undefined,
    city: appleShippingAddress.locality,
    stateProvinceRegion: appleShippingAddress.administrativeArea,
    country: appleShippingAddress.countryCode,
    postalCode: appleShippingAddress.postalCode,
    phonePrimary: {
      phoneNumber: appleShippingAddress.phoneNumber,
    },
  };
};
Google Pay Frontend Integration Example
import React, { FC, useEffect, useState } from 'react';
import { get, isEmpty, reduce, toString } from 'lodash';

import UIElement from '@adyen/adyen-web/dist/types/components/UIElement';
import Checkout from '@adyen/adyen-web/dist/types/core';
import { PaymentMethodOptions } from '@adyen/adyen-web/dist/types/types';

import { useCartContext } from '@app/cart/contexts';
import {
  useGetPaymentAmount,
  useUpdateFulfillmentGroup,
} from '@app/checkout/hooks';
import { AdyenPaymentForm } from '@app/checkout/components';
import {
  Address,
  Cart,
  DefaultPaymentType,
  FulfillmentAddress,
  PricedFulfillmentOption,
  UpdateFulfillmentGroupRequest,
} from '@broadleaf/commerce-cart';

import {
  getShipGroup,
  getShipGroupPriceFulfillmentOption,
  getShipGroupReferenceNumber,
} from '@app/cart/utils';
import { useFormatNumber } from '@app/common/hooks';
import { areAddressesEquivalent } from '@broadleaf/payment-js';
import { AdyenExpressCheckoutButtonContainer } from '@app/checkout/components/adyen-payment/express-checkout/adyen-express-checkout-button-container';

import {
  useHandleSubmitAdyenPayment,
  useUpdateContactInfoOrGenerateGuestToken,
  useUpdateFulfillmentAddress,
  useUpdateSelectedFulfillmentOption,
} from '../adyen-hooks';

type Props = {
  checkout: Checkout;
  setErrorMsg: (msg: string) => void;
};

export const GooglePayExpressCheckout: FC<Props> = ({
  checkout,
  setErrorMsg,
}) => {
  const [paymentData, setPaymentData] = useState();
  const [submitState, setSubmitState] = useState();
  const [adyenComponent, setAdyenComponent] = useState<UIElement>();

  const { configuration } = useComponentConfiguration({
    onSubmit: (state, component) => {
      setSubmitState(state);
      setAdyenComponent(component);
    },
    onAuthorized: paymentData => {
      setPaymentData(paymentData);
    },
  });

  const { setCart } = useCartContext();

  const handleSubmitAdyenPayment = useHandleSubmitAdyenPayment();

  const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();
  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken();

  useEffect(() => {
    if (submitState && paymentData && adyenComponent) {
      const handleSubmit = async () => {
        let newCart: Cart;

        try {
          const email = get(paymentData, 'email');
          const googleShippingAddress = get(paymentData, 'shippingAddress');

          newCart = await updateContactInfoOrGenerateGuestToken(email);

          const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
          const shippingAddress = buildShippingAddress(googleShippingAddress);

          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);
          }

          await handleSubmitAdyenPayment({
            state: submitState,
            paymentData,
            paymentType: DefaultPaymentType.GOOGLE_PAY_EXPRESS,
            component: adyenComponent,
          });
        } catch (err) {
          console.error('An error occurred while processing the request', err);
          setErrorMsg(toString(err));
        }
      };

      handleSubmit();
    }
  }, [
    submitState,
    paymentData,
    handleSubmitAdyenPayment,
    adyenComponent,
    updateContactInfoOrGenerateGuestToken,
    updateFulfillmentGroup,
    setCart,
    setErrorMsg,
  ]);

  if (isEmpty(configuration)) {
    return null;
  }

  return (
    <AdyenExpressCheckoutButtonContainer
      type="googlepay"
      configuration={configuration}
      checkout={checkout}
    />
  );
};

const useComponentConfiguration = ({ onSubmit, onAuthorized }) => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const onPaymentDataChanged = useOnPaymentDataChanged();

  const [configuration, setConfiguration] =
    useState<PaymentMethodOptions<'googlepay'>>();

  useEffect(() => {
    const amount = getPaymentAmount();

    const googlePayComponentConfiguration = {
      environment: 'TEST',
      isExpress: true,
      buttonType: 'checkout',
      buttonSizeMode: 'fill',
      countryCode: 'US',
      emailRequired: true,
      callbackIntents: ['SHIPPING_ADDRESS', 'SHIPPING_OPTION'],
      shippingAddressRequired: true,
      shippingAddressParameters: {
        allowedCountryCodes: ['US'],
        phoneNumberRequired: true,
      },
      shippingOptionRequired: true,
      transactionInfo: {
        countryCode: 'US',
        currencyCode: amount.currency,
        totalPriceStatus: 'ESTIMATED',
        totalPrice: toString(amount.amount),
      },
      billingAddressRequired: true,
      billingAddressParameters: {
        format: 'FULL',
      },
      paymentDataCallbacks: {
        onPaymentDataChanged,
      },
      onSubmit,
      onAuthorized,
    } as PaymentMethodOptions<'googlepay'>;

    setConfiguration(googlePayComponentConfiguration);
    // eslint-disable-next-line
  }, [getPaymentAmount]);

  return {
    configuration,
  };
};

const useCalculateNewTransactionInfo = () => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  return (cart: Cart): google.payments.api.TransactionInfo => {
    const paymentAmount = getPaymentAmount(cart);

    const fulfillmentTotal = cart?.cartPricing?.fulfillmentTotal;

    return {
      countryCode: 'US',
      currencyCode: paymentAmount.currency,
      totalPrice: toString(paymentAmount.amount),
      totalPriceStatus: fulfillmentTotal ? 'FINAL' : 'ESTIMATED',
    } as google.payments.api.TransactionInfo;
  };
};

const useOnPaymentDataChanged = () => {
  const updateFulfillmentAddress = useUpdateFulfillmentAddress({
    buildFulfillmentAddress,
  });
  const buildShippingOptionParameters = useBuildShippingOptionParameters();
  const calculateNewTransactionInfo = useCalculateNewTransactionInfo();
  const updateSelectedFulfillmentOption = useUpdateSelectedFulfillmentOption();

  return async (
    intermediatePaymentData: google.payments.api.IntermediatePaymentData
  ): Promise<google.payments.api.PaymentDataRequestUpdate> => {
    const { callbackTrigger, shippingAddress, shippingOptionData } =
      intermediatePaymentData;
    const paymentDataRequestUpdate: google.payments.api.PaymentDataRequestUpdate =
      {};

    // If it initializes or changes the shipping address, calculate the shipping options and transaction info.
    if (
      callbackTrigger === 'INITIALIZE' ||
      callbackTrigger === 'SHIPPING_ADDRESS'
    ) {
      const { fulfillmentOptions, cart } = await updateFulfillmentAddress(
        shippingAddress
      );

      paymentDataRequestUpdate.newShippingOptionParameters =
        buildShippingOptionParameters(fulfillmentOptions, cart);
      paymentDataRequestUpdate.newTransactionInfo =
        calculateNewTransactionInfo(cart);
    }

    // If SHIPPING_OPTION changes, calculate the new shipping amount.
    if (callbackTrigger === 'SHIPPING_OPTION') {
      const updatedCart = await updateSelectedFulfillmentOption(
        shippingOptionData.id
      );
      paymentDataRequestUpdate.newTransactionInfo =
        calculateNewTransactionInfo(updatedCart);
    }

    return paymentDataRequestUpdate;
  };
};

type ShippingOptionParameters = {
  defaultSelectedOptionId?: string;
  shippingOptions: google.payments.api.SelectionOption[];
};

const useBuildShippingOptionParameters = () => {
  const formatNumber = useFormatNumber();

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

    let defaultSelectedOptionId = undefined;

    const shippingOptions = reduce(
      fulfillmentOptions,
      (
        result: google.payments.api.SelectionOption[],
        fulfillmentOption: PricedFulfillmentOption
      ) => {
        const formattedAmount = formatNumber(fulfillmentOption.price.amount, {
          style: 'currency',
          currency: fulfillmentOption.price.currency,
        });

        const shippingOption = {
          id: fulfillmentOption.description,
          label: `${formattedAmount}: ${fulfillmentOption.description}`,
          description: fulfillmentOption.description,
        };

        if (shippingOption.id === currentPricedFulfillmentOption?.description) {
          defaultSelectedOptionId = shippingOption.id;
        }

        result.push(shippingOption);

        return result;
      },
      []
    );

    return {
      defaultSelectedOptionId,
      shippingOptions,
    };
  };
};

const buildFulfillmentAddress = (
  shippingAddress:
    | google.payments.api.IntermediateAddress
    | google.payments.api.Address
): FulfillmentAddress => {
  if ('address1' in shippingAddress) {
    return {
      address1: shippingAddress.address1,
      address2: shippingAddress.address2,
      city: shippingAddress.locality,
      region: shippingAddress.administrativeArea,
      country: shippingAddress.countryCode,
      postalCode: shippingAddress.postalCode,
    } as FulfillmentAddress;
  }

  return {
    city: shippingAddress.locality,
    region: shippingAddress.administrativeArea,
    country: shippingAddress.countryCode,
    postalCode: shippingAddress.postalCode,
  } as FulfillmentAddress;
};

const buildShippingAddress = (
  googleShippingAddress: google.payments.api.Address
): Address => {
  return {
    fullName: googleShippingAddress.name,
    addressLine1: googleShippingAddress.address1,
    addressLine2: googleShippingAddress.address2,
    city: googleShippingAddress.locality,
    stateProvinceRegion: googleShippingAddress.administrativeArea,
    country: googleShippingAddress.countryCode,
    postalCode: googleShippingAddress.postalCode,
    phonePrimary: {
      phoneNumber: googleShippingAddress.phoneNumber,
    },
  };
};
PayPal Frontend Integration Example
import React, { FC, useEffect, useState } from 'react';
import { get, isEmpty, isNil, join, reduce } from 'lodash';

import UIElement from '@adyen/adyen-web/dist/types/components/UIElement';
import PaypalElement from '@adyen/adyen-web/dist/types/components/PayPal';
import {
  PaymentMethodOptions,
  ShopperDetails,
} from '@adyen/adyen-web/dist/types/types';
import Checkout from '@adyen/adyen-web/dist/types/core';

import { useCartContext } from '@app/cart/contexts';
import {
  useFormatAmountInMinorUnits,
  useGetPaymentAmount,
  useGetPaymentCallbackUrl,
  useHandleSubmitCart,
  useUpdateFulfillmentGroup,
} from '@app/checkout/hooks';
import { AdyenPaymentForm } from '@app/checkout/components';

import {
  Address,
  Cart,
  FulfillmentAddress,
  PaymentRequest,
  PaymentSummary,
  PricedFulfillmentOption,
  UpdateFulfillmentGroupRequest,
  UpdatePaymentRequest,
} from '@broadleaf/commerce-cart';

import {
  getShipGroup,
  getShipGroupPriceFulfillmentOption,
  getShipGroupReferenceNumber,
} from '@app/cart/utils';
import { AdyenExpressCheckoutButtonContainer } from '@app/checkout/components/adyen-payment/express-checkout/adyen-express-checkout-button-container';

import {
  useEventCallback,
  useFormatMessage,
  usePaymentAuthState,
} from '@app/common/hooks';
import { useFetchFulfillmentOptionsForCartItems } from '@app/cart/hooks';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import { areAddressesEquivalent } from '@broadleaf/payment-js';
import { usePaymentClient } from '@app/common/contexts';
import { pushGtmAddPayment } from '@app/common/utils';

import {
  useUpdateContactInfoOrGenerateGuestToken,
  useUpdateFulfillmentAddress,
  useUpdateSelectedFulfillmentOption,
} from '../adyen-hooks';

import messages from '@app/checkout/messages';

type Props = {
  checkout: Checkout;
  setErrorMsg: (msg: string) => void;
};

export const PayPalExpressCheckout: FC<Props> = ({ checkout, setErrorMsg }) => {
  const { configuration } = useComponentConfiguration(setErrorMsg);

  if (isEmpty(configuration)) {
    return null;
  }

  return (
    <AdyenExpressCheckoutButtonContainer
      type="paypal"
      configuration={configuration}
      checkout={checkout}
    />
  );
};

const createPaymentSummaryStore = () => {
  let value = null;
  return {
    setValue(newValue: unknown) {
      value = newValue;
    },
    getValue() {
      return value;
    },
  };
};
const paymentSummaryStore = createPaymentSummaryStore();

const useComponentConfiguration = setErrorMsg => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const [configuration, setConfiguration] =
    useState<PaymentMethodOptions<'paypal'>>();

  const authState = usePaymentAuthState();
  const cartState = useCartContext();
  const { cart } = cartState;
  const paymentClient = usePaymentClient();

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

  const handleShippingAddressChange = useHandleShippingAddressChange({
    setErrorMsg,
  });

  const handleShippingOptionChange = useHandleShippingOptionChange();
  const handleAdditionalDetails = useHandleAdditionalDetails({ setErrorMsg });
  const handleShopperDetails = useHandleShopperDetails({ setErrorMsg });

  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken();

  useEffect(() => {
    const paypalComponentConfiguration = {
      environment: 'test',
      isExpress: true,
      countryCode: 'US',
      emailRequired: true,
      blockPayPalVenmoButton: true,
      blockPayPalCreditButton: true,
      blockPayPalPayLaterButton: true,
      onSubmit: async (state, component) => {
        await handleSubmit(state, component);
      },
      onShippingAddressChange: handleShippingAddressChange,
      onShippingOptionsChange: handleShippingOptionChange,
      onAdditionalDetails: handleAdditionalDetails,
      onShopperDetails: handleShopperDetails,
    } as PaymentMethodOptions<'paypal'>;

    setConfiguration(paypalComponentConfiguration);
    // eslint-disable-next-line
  }, [
    handleAdditionalDetails,
    handleShippingAddressChange,
    handleShippingOptionChange,
    handleShopperDetails,
  ]);

  async function handleSubmit(state, component: UIElement) {
    component.setStatus('loading');

    const amount = getPaymentAmount(cart);
    const returnUrl = getPaymentCallbackUrl({
      gatewayType: AdyenPaymentForm.TYPE,
    });

    const createPaymentRequest = {
      name: AdyenPaymentForm.TYPE,
      type: 'PAYPAL_EXPRESS',
      gatewayType: AdyenPaymentForm.TYPE,
      amount: amount,
      subtotal: cart.cartPricing.subtotal,
      adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
      fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
      taxTotal: cart.cartPricing.totalTax,
      isSingleUsePaymentMethod: true,
      shouldArchiveExistingPayments: true,
      paymentMethodProperties: {
        returnUrl,
      },
    } as PaymentRequest;
    //generate guest token, otherwise payment will become archived
    await updateContactInfoOrGenerateGuestToken();
    try {
      const paymentSummary = await handleSubmitPaymentInfo(
        createPaymentRequest
      );

      paymentSummaryStore.setValue(paymentSummary);
      component.setStatus('ready');
      return handleResponse(paymentSummary, component);
    } catch (err) {
      console.error('EXCEPTION FOR PAYMENT CREATION:', err);
      setErrorMsg('Error during payment creation.' + err?.message);
      component.setStatus('error');
      component.unmount();
    }
  }

  async function handleResponse(response: PaymentSummary, component) {
    if (response.nextAction) {
      component.handleAction(response.nextAction.attributes);
      return response;
    }
    return null;
  }

  return {
    configuration,
  };
};

const useHandleShippingAddressChange = ({ setErrorMsg }) => {
  const { cart } = useCartContext();
  const updateFulfillmentAddress = useUpdateFulfillmentAddress({
    buildFulfillmentAddress,
  });

  const buildShippingOptionParameters = useBuildShippingOptionParameters();

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

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

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

  return useEventCallback(async (data, actions, component: PaypalElement) => {
    const { fulfillmentOptions, cart } = await updateFulfillmentAddress(
      get(data, 'shippingAddress')
    );
    const shippingOptions = buildShippingOptionParameters(
      fulfillmentOptions,
      cart
    );

    const paymentAmount = getPaymentAmount(cart);

    const paymentId = paymentSummaryStore.getValue().paymentId;

    const paymentUpdateRequest = {
      paymentId,
      amount: paymentAmount,
      taxTotal: cart.cartPricing.totalTax,
      includedTaxTotal: cart.cartPricing.includedTaxAmount,
      gatewayUpdateParams: {
        paymentData: component.paymentData,
        deliveryMethods: shippingOptions,
      },
    } as UpdatePaymentRequest;

    try {
      const updatedPaymentSummary = await handleUpdatePaymentInfo(
        paymentUpdateRequest,
        paymentSummaryStore.getValue().version
      );
      paymentSummaryStore.setValue(updatedPaymentSummary);

      component.updatePaymentData(
        get(updatedPaymentSummary.gatewayUpdateResponse, 'paymentData')
      );
    } catch (err) {
      console.error('EXCEPTION FOR PAYMENT UPDATE:', err);
      setErrorMsg('Error during payment update.' + err?.message);
      component.setStatus('error');
      component.unmount();
    }
  }, []);
};

const useHandleShippingOptionChange = () => {
  const { cart, setCart } = useCartContext();
  const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems();

  const updateSelectedFulfillmentOption = useUpdateSelectedFulfillmentOption();
  const buildShippingOptionParameters = useBuildShippingOptionParameters();

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

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

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

  return useEventCallback(async (data, actions, component: PaypalElement) => {
    const updatedCart = await updateSelectedFulfillmentOption(
      get(get(data, 'selectedShippingOption'), 'id')
    );
    setCart(updatedCart);
    const paymentAmount = getPaymentAmount(updatedCart);
    const shippingOptions = buildShippingOptionParameters(
      fulfillmentOptions,
      updatedCart
    );

    const paymentId = paymentSummaryStore.getValue().paymentId;

    const paymentUpdateRequest = {
      paymentId,
      amount: paymentAmount,
      taxTotal: updatedCart.cartPricing.totalTax,
      includedTaxTotal: updatedCart.cartPricing.includedTaxAmount,
      gatewayUpdateParams: {
        paymentData: component.paymentData,
        deliveryMethods: shippingOptions,
      },
    } as UpdatePaymentRequest;

    const updatedPaymentSummary = await handleUpdatePaymentInfo(
      paymentUpdateRequest,
      paymentSummaryStore.getValue().version
    );
    paymentSummaryStore.setValue(updatedPaymentSummary);

    component.updatePaymentData(
      get(updatedPaymentSummary.gatewayUpdateResponse, 'paymentData')
    );
  }, []);
};

const useHandleAdditionalDetails = ({ setErrorMsg }) => {
  const formatMessage = useFormatMessage();
  const { cart } = useCartContext();

  const { error: submitCartError, onSubmit: submitCart } =
    useHandleSubmitCart();

  useEffect(() => {
    if (submitCartError) {
      setErrorMsg(formatMessage(messages.genericError));
    }
  }, [submitCartError, formatMessage, setErrorMsg]);

  const authState = usePaymentAuthState();

  return useEventCallback(async (state, component: UIElement) => {
    try {
      component.setStatus('loading');
      const paymentSummary = paymentSummaryStore.getValue();
      pushGtmAddPayment(cart, paymentSummary);
      const data = get(state, 'data');
      await submitCart({
        sensitivePaymentMethodData: [
          {
            paymentId: paymentSummary.paymentId,
            paymentMethodProperties: {
              ADYEN_PAYMENT_DATA: {
                ...data,
                shopperEmail: authState.isAuthenticated
                  ? authState.customerEmail
                  : cart.emailAddress,
              },
            },
          },
        ],
      });
    } catch (err) {
      setErrorMsg('Error executing car submission.' + err?.message);
      component.setStatus('error');
      component.unmount();
    }
  }, []);
};

const useHandleShopperDetails = ({ setErrorMsg }) => {
  const { cart, setCart } = useCartContext();

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken();

  const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();

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

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

  return useEventCallback(
    async (
      shopperDetails: ShopperDetails,
      rawData,
      actions: {
        resolve: () => void;
        reject: () => void;
      }
    ) => {
      let newCart = await updateContactInfoOrGenerateGuestToken(
        shopperDetails.shopperEmail
      );
      const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
      const shippingAddress = buildShippingAddress(shopperDetails);

      const currentShippingAddress = getShipGroup(newCart)?.address;

      const addressesEquivalent = areAddressesEquivalent(
        shippingAddress,
        currentShippingAddress
      );

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

      const paymentUpdateRequest = {
        paymentId: paymentSummaryStore.getValue().paymentId,
        billingAddress: mapBillingAddress(shopperDetails),
      } as UpdatePaymentRequest;

      try {
        const updatedPaymentSummary = await handleUpdatePaymentInfo(
          paymentUpdateRequest,
          paymentSummaryStore.getValue().version
        );
        paymentSummaryStore.setValue(updatedPaymentSummary);
      } catch (err) {
        console.error('EXCEPTION FOR PAYMENT UPDATE:', err);
        setErrorMsg('Error during payment update.' + err?.message);
      }

      actions.resolve();
    },
    []
  );
};

type ShippingOption = {
  reference: string;
  description: string;
  type: string;
  amount: {
    value: number;
    currency: string;
  };
  selected: boolean;
};

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

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

    return reduce(
      fulfillmentOptions,
      (
        result: ShippingOption[],
        fulfillmentOption: PricedFulfillmentOption
      ) => {
        const amountInMinorUnits = formatAmountInMinorUnits({
          amount: fulfillmentOption.price.amount,
          currency: fulfillmentOption.price.currency,
        });

        const shippingOption = {
          reference: fulfillmentOption.description,
          description: fulfillmentOption.description,
          type: 'Shipping',
          amount: {
            value: amountInMinorUnits,
            currency: fulfillmentOption.price.currency,
          },
          selected: false,
        } as ShippingOption;

        if (
          shippingOption.reference ===
          currentPricedFulfillmentOption?.description
        ) {
          shippingOption.selected = true;
        }

        result.push(shippingOption);

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

type PayPalShippingAddress = {
  city: string;
  countryCode: string;
  postalCode: string;
  state: string;
};

const buildFulfillmentAddress = (
  shippingAddress: PayPalShippingAddress
): FulfillmentAddress => {
  return {
    city: shippingAddress.city,
    region: shippingAddress.state,
    country: shippingAddress.countryCode,
    postalCode: shippingAddress.postalCode,
  } as FulfillmentAddress;
};

const buildShippingAddress = (shopperDetails: ShopperDetails): Address => {
  return {
    fullName: getShopperFullName(shopperDetails),
    emailAddress: shopperDetails.shopperEmail,
    addressLine1: shopperDetails.shippingAddress.street,
    city: shopperDetails.shippingAddress.city,
    stateProvinceRegion: shopperDetails.shippingAddress.stateOrProvince,
    country: shopperDetails.shippingAddress.country,
    postalCode: shopperDetails.shippingAddress.postalCode,
    phonePrimary: {
      phoneNumber: shopperDetails.telephoneNumber,
    },
  };
};

const mapBillingAddress = (shopperDetails: ShopperDetails): Address => {
  const {
    houseNumberOrName,
    street,
    city,
    stateOrProvince,
    country,
    postalCode,
  } = shopperDetails.billingAddress;

  const fullName = getShopperFullName(shopperDetails);

  const addressLine1 =
    !isNil(houseNumberOrName) && houseNumberOrName !== 'N/A'
      ? join([houseNumberOrName, street], ', ')
      : street;

  return {
    fullName,
    addressLine1,
    city: city,
    stateProvinceRegion: stateOrProvince,
    country: country,
    postalCode: postalCode,
  };
};

const getShopperFullName = (shopperDetails: ShopperDetails): string => {
  const { shopperName, billingAddress } = shopperDetails;

  const firstName = shopperName?.firstName || billingAddress.firstName;
  const lastName = shopperName?.lastName || billingAddress.lastName;

  return join([firstName, lastName], ' ');
};
Common Hooks and Components

Express Checkout Button Container

import { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';

import Core from '@adyen/adyen-web/dist/types/core';
import { PaymentMethodOptions } from '@adyen/adyen-web/dist/types/types';
import GooglePay from '@adyen/adyen-web/dist/types/components/GooglePay';
import ApplePayElement from '@adyen/adyen-web/dist/types/components/ApplePay';

type PaymentMethodType = 'googlepay' | 'applepay' | 'paypal';

interface IContainer {
  type: PaymentMethodType;
  configuration: PaymentMethodOptions<PaymentMethodType>;
  checkout: Core;
  className?: string;
}

export const AdyenExpressCheckoutButtonContainer = ({
  type,
  configuration,
  checkout,
  className,
}: IContainer) => {
  const container = useRef(null);

  const [isPaymentTypeAvailable, setIsPaymentTypeAvailable] =
    useState<boolean>(false);

  useEffect(() => {
    if (!checkout || !container.current) {
      return;
    }

    if (checkout.paymentMethodsResponse?.has(type)) {
      const element = checkout.create(type, { ...configuration });

      if (type === 'googlepay' || type === 'applepay') {
        (element as GooglePay | ApplePayElement)
          .isAvailable()
          .then(() => {
            element.mount(container.current);
            setIsPaymentTypeAvailable(true);
          })
          .catch(() => {
            setIsPaymentTypeAvailable(false);
          });
      } else if (type === 'paypal') {
        element.mount(container.current);
        setIsPaymentTypeAvailable(true);
      }
    }
    // eslint-disable-next-line
  }, [checkout, setIsPaymentTypeAvailable]);

  return (
    <div
      ref={container}
      id={`adyen-${type}-express-checkout-button`}
      className={classNames('mt-1', {
        [className]: isPaymentTypeAvailable,
        hidden: !isPaymentTypeAvailable,
      })}
    />
  );
};

Wallet Express Checkout Buttons

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

import classNames from 'classnames';

import {
  GooglePayExpressCheckout,
  ApplePayExpressCheckout,
  PayPalExpressCheckout,
} from '@app/checkout/components/adyen-payment/express-checkout';
import { useCreateAdyenCheckout } from '@app/checkout/components/adyen-payment/adyen-hooks';

type Props = {
  className?: string;
};

export const AdyenWalletExpressCheckout: FC<Props> = ({ className }) => {
  const [errorMsg, setErrorMsg] = useState<string>();

  const { checkout } = useCreateAdyenCheckout({ setErrorMsg });

  return (
    <div className={classNames(className, 'adyen-express-checkout')}>
      <GooglePayExpressCheckout checkout={checkout} setErrorMsg={setErrorMsg} />
      <ApplePayExpressCheckout checkout={checkout} setErrorMsg={setErrorMsg} />
      <PayPalExpressCheckout checkout={checkout} setErrorMsg={setErrorMsg} />
      {errorMsg && <h1>{errorMsg}</h1>}
    </div>
  );
};

Common Hooks

import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { find, get, groupBy, isEmpty, join, toString, noop } from 'lodash';
import { useRouter } from 'next/router';

import UIElement from '@adyen/adyen-web/dist/types/components/UIElement';
import Checkout from '@adyen/adyen-web/dist/types/core';
import AdyenCheckout from '@adyen/adyen-web';

import { useCartContext } from '@app/cart/contexts';
import { useEventCallback, usePaymentAuthState } from '@app/common/hooks';
import { AdyenPaymentForm } from '@app/checkout/components';
import {
  useAdyenPaymentServicesClient,
  useCheckoutClient,
  useLocaleContext,
  usePaymentClient,
  usePreviewOptions,
} from '@app/common/contexts';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import {
  useFormatAmountInMinorUnits,
  useGetPaymentAmount,
  useGetPaymentCallbackUrl,
  useHandleMoneyAddition,
  useHandleMoneySubtraction,
  useHandleSubmitCart,
} from '@app/checkout/hooks';
import {
  Address,
  Cart,
  CheckoutClientCallOptions,
  DefaultPaymentType,
  GuestTokenResponse,
  PaymentRequest,
  PaymentSummary,
  PricedFulfillmentOption,
} from '@broadleaf/commerce-cart';
import {
  AdyenConfig,
  pushGtmAddPayment,
  pushGtmAddShipping,
} from '@app/common/utils';
import { useAdyenGetPaymentMethods } from '@broadleaf/adyen-payment-services-react';
import {
  useFetchFulfillmentOptionsForCartItems,
  useSelectFulfillmentOption,
  useUpdateFulfillmentGroupAddress,
} from '@app/cart/hooks';
import { getShipGroupReferenceNumber } from '@app/cart/utils';
import { useAuth } from '@broadleaf/auth-react';
import { useCsrContext } from '@app/csr/context';
import { useGetCustomerAccessToken } from '@app/auth/hooks';

type Props = {
  setErrorMsg: (msg: string) => void;
};

type CreateCheckoutResponse = {
  checkout: Checkout;
};

export const useCreateAdyenCheckout = ({
  setErrorMsg,
}: Props): CreateCheckoutResponse => {
  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: AdyenPaymentForm.TYPE,
  });

  const { currentLocale: locale } = useLocaleContext();

  const [checkout, setCheckout] = useState<Checkout>();

  const authState = usePaymentAuthState();

  const adyenPaymentServicesClient = useAdyenPaymentServicesClient();

  const { getPaymentMethods } = useAdyenGetPaymentMethods({
    authState,
    adyenPaymentServicesClient,
  });

  const shopperReference = authState.isAuthenticated
    ? authState.customerId
    : undefined;

  useEffect(() => {
    async function createAdyenCheckout() {
      if (!checkout && locale) {
        const amount = getPaymentAmount();

        const paymentMethodsResponse = await getPaymentMethods({
          amount,
          shopperLocale: locale,
          countryCode: 'US',
          shopperReference,
        });

        const adyenCheckout = await AdyenCheckout({
          environment: 'test',
          clientKey: AdyenConfig.CLIENT_KEY,
          analytics: {
            enabled: false,
          },
          locale: locale,
          countryCode: 'US',
          showPayButton: true,
          paymentMethodsResponse,
          onError: err => {
            console.error(err?.cause);
            setErrorMsg(toString(err));
          },
        });

        setCheckout(adyenCheckout);
      }
    }

    void createAdyenCheckout();
  }, [
    checkout,
    getPaymentAmount,
    getPaymentMethods,
    locale,
    setErrorMsg,
    shopperReference,
  ]);

  return {
    checkout,
  };
};

type HandleSubmitProps = {
  setError?: unknown;
};

type HandleSubmitParameters = {
  state: unknown;
  component?: UIElement;
  paymentType: string;
  sessionId?: string;
  paymentData?: unknown;
};

type HandleSubmitResponse = {
  (params: HandleSubmitParameters): Promise<PaymentSummary | undefined>;
};

export const useHandleSubmitAdyenPayment = (
  props: HandleSubmitProps = {}
): HandleSubmitResponse => {
  const { setError = noop } = props;
  const cartState = useCartContext();
  const { cart } = cartState;
  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();
  const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
    authState,
    payments: undefined,
    ownerId: cart?.id,
    owningUserEmailAddress: cart.emailAddress,
    paymentClient,
    multiplePaymentsAllowed: false,
    rejectOnError: true,
  });

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

  const getPaymentCallbackUrl = useGetPaymentCallbackUrl();

  const { error, onSubmit: submitCart } = useHandleSubmitCart();

  const [adyenComponent, setAdyenComponent] = useState<UIElement>();

  useEffect(() => {
    if (!adyenComponent) {
      return;
    }

    const errorType = get(error, 'failureType');
    if (
      errorType === 'PAYMENT_REQUIRES_3DS_VERIFICATION' ||
      errorType === 'PAYMENT_REQUIRES_EXTERNAL_INTERACTION' ||
      errorType === 'PAYMENT_REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION'
    ) {
      const errorDetails = find(
        get(error, 'paymentTransactionFailureDetails'),
        ({ failureType }) =>
          failureType === 'REQUIRES_3DS_VERIFICATION' ||
          failureType === 'REQUIRES_EXTERNAL_INTERACTION' ||
          failureType === 'REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION'
      );

      const action = get(errorDetails, 'nextAction.attributes');

      adyenComponent.handleAction(action);
    } else if (errorType && error) {
      setError(error);
    }
  }, [error, adyenComponent, setError]);

  const getLineItems = useGetLineItems();

  return useEventCallback(
    async ({
      state,
      paymentType,
      component,
      sessionId,
      paymentData,
    }: HandleSubmitParameters) => {
      setError(undefined);
      const data = get(state, 'data');

      setAdyenComponent(component);

      const billingAddress = getBillingAddress(state, paymentType, paymentData);

      // these are required to pass to the backend as part of the payment
      const returnUrl = getPaymentCallbackUrl({
        gatewayType: AdyenPaymentForm.TYPE,
      });

      const amount = getPaymentAmount(cart);
      const paymentRequest = {
        name: AdyenPaymentForm.TYPE,
        type: paymentType,
        gatewayType: AdyenPaymentForm.TYPE,
        amount,
        subtotal: cart.cartPricing.subtotal,
        adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
        fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
        taxTotal: cart.cartPricing.totalTax,
        isSingleUsePaymentMethod: true,
        shouldArchiveExistingPayments: true,
        paymentMethodProperties: {
          sessionId,
          returnUrl,
        },
        billingAddress,
      } as PaymentRequest;

      let paymentSummary;

      try {
        paymentSummary = await handleSubmitPaymentInfo(paymentRequest);

        if (paymentSummary) {
          pushGtmAddPayment(cart, paymentSummary);

          const countryCode = cart.fulfillmentGroups[0]?.address?.country;
          const lineItems = getLineItems(cart);

          const checkoutResponse = await submitCart({
            sensitivePaymentMethodData: [
              {
                paymentId: paymentSummary.paymentId,
                paymentMethodProperties: {
                  ADYEN_PAYMENT_DATA: {
                    ...data,
                    shopperEmail: authState.isAuthenticated
                      ? authState.customerEmail
                      : cart.emailAddress,
                    lineItems,
                    countryCode: countryCode,
                  },
                },
              },
            ],
          });

          return checkoutResponse?.paymentSummaries[0];
        }
      } catch (err) {
        console.error('There was an error adding payment information', err);
        setError(err);
      }
    },
    []
  );
};

const getBillingAddress = (
  state,
  paymentType,
  paymentData
): Address | undefined => {
  if (
    paymentType === DefaultPaymentType.CREDIT_CARD ||
    paymentType === 'KLARNA'
  ) {
    return getBillingDetailsForCreditCard(get(state, 'data'));
  } else if (
    paymentType === DefaultPaymentType.GOOGLE_PAY ||
    paymentType === DefaultPaymentType.GOOGLE_PAY_EXPRESS
  ) {
    return getBillingDetailsForGooglePayment(paymentData);
  } else if (
    paymentType === DefaultPaymentType.APPLE_PAY ||
    paymentType === DefaultPaymentType.APPLE_PAY_EXPRESS
  ) {
    return getBillingDetailsForApplePayment(paymentData);
  }
};

const getBillingDetailsForCreditCard = (data): Address | undefined => {
  const bAddress = get(data, 'billingAddress', {});

  if (isEmpty(bAddress)) {
    return;
  }

  const holderName = get(data, 'paymentMethod.holderName');

  return {
    fullName: holderName,
    addressLine1: join([bAddress.street, bAddress.houseNumberOrName], ', '),
    city: bAddress.city,
    stateProvinceRegion: bAddress.stateOrProvince,
    country: bAddress.country,
    postalCode: bAddress.postalCode,
  };
};

const getBillingDetailsForGooglePayment = (googlePaymentData): Address => {
  const googleBillingAddress = get(
    googlePaymentData,
    'paymentMethodData.info.billingAddress'
  );

  if (isEmpty(googleBillingAddress)) {
    return;
  }

  return {
    fullName: googleBillingAddress.name,
    addressLine1: googleBillingAddress.address1,
    addressLine2: googleBillingAddress.address2,
    city: googleBillingAddress.locality,
    stateProvinceRegion: googleBillingAddress.administrativeArea,
    country: googleBillingAddress.countryCode,
    postalCode: googleBillingAddress.postalCode,
  };
};

const getBillingDetailsForApplePayment = (
  applePaymentData: ApplePayJS.ApplePayPaymentAuthorizedEvent
): Address => {
  const appleBillingAddress = applePaymentData.payment.billingContact;

  if (isEmpty(appleBillingAddress)) {
    return;
  }

  return {
    fullName: `${appleBillingAddress.givenName} ${appleBillingAddress.familyName}`,
    addressLine1: appleBillingAddress.addressLines[0],
    addressLine2:
      appleBillingAddress.addressLines.length > 1
        ? appleBillingAddress.addressLines[1]
        : undefined,
    city: appleBillingAddress.locality,
    stateProvinceRegion: appleBillingAddress.administrativeArea,
    country: appleBillingAddress.countryCode,
    postalCode: appleBillingAddress.postalCode,
  };
};

export const useGetLineItems = () => {
  const handleMoneyAddition = useHandleMoneyAddition();
  const handleMoneySubtraction = useHandleMoneySubtraction();
  const formatAmountInMinorUnits = useFormatAmountInMinorUnits();

  return useEventCallback((cart: Cart) => {
    const cartItemById = groupBy(cart.cartItems, 'id');
    return cart.fulfillmentGroups[0].fulfillmentItems.map(fi => {
      const fulfillmentCosts = {
        amount: handleMoneyAddition(
          fi.fulfillmentTotal,
          get(fi, 'proratedFulfillmentCharge')
        ),
        currency: fi.merchandiseTotalAmount.currency,
      };
      const fulfillmentCostWithAdjustmentsApplied = {
        amount: handleMoneySubtraction(
          fulfillmentCosts,
          fi.proratedFulfillmentGroupAdjustments
        ),
        currency: fi.merchandiseTotalAmount.currency,
      };
      let merchandiseAndFulfillmentTax = {
        amount: 0,
        currency: fi.merchandiseTotalAmount.currency,
      };
      fi.fulfillmentItemTaxDetails.forEach(taxDetail => {
        merchandiseAndFulfillmentTax = {
          amount: handleMoneyAddition(
            taxDetail.taxCalculated,
            merchandiseAndFulfillmentTax
          ),
          currency: fi.merchandiseTotalAmount.currency,
        };
      });
      const withTax = {
        amount: handleMoneyAddition(
          fulfillmentCostWithAdjustmentsApplied,
          merchandiseAndFulfillmentTax
        ),
        currency: fi.merchandiseTotalAmount.currency,
      };
      let finalAmount = withTax;

      if (fi.merchandiseTaxableAmount) {
        finalAmount = {
          amount: handleMoneyAddition(withTax, fi.merchandiseTaxableAmount),
          currency: fi.merchandiseTotalAmount.currency,
        };
      }
      return {
        description: cartItemById[fi.cartItemId][0].name,
        sku: cartItemById[fi.cartItemId][0].sku,
        quantity: fi.quantity,
        amountIncludingTax: formatAmountInMinorUnits(finalAmount),
        imageUrl: cartItemById[fi.cartItemId][0].imageAsset?.contentUrl,
      };
    });
  }, []);
};

type PaymentCompletedProps = {
  setUIElement?: Dispatch<SetStateAction<UIElement | undefined>>;
};

export const useHandleOnPaymentCompleted = (
  props: PaymentCompletedProps = {}
) => {
  const { setUIElement } = props;

  const router = useRouter();
  const { cart } = useCartContext();

  const authState = usePaymentAuthState();

  return useEventCallback(async (result, element: UIElement) => {
    const resultCode = get(result, 'resultCode');
    if ('Authorised' === resultCode) {
      const emailAddress = authState.isAuthenticated
        ? undefined
        : cart.emailAddress;
      await router.push({
        pathname: '/checkout/payment-confirmation',
        query: {
          cart_id: cart.id,
          email_address: emailAddress,
          payment_finalization_status: 'FINALIZED',
          payment_result_status: 'SUCCESS',
          gateway_type: AdyenPaymentForm.TYPE,
        },
      });
    } else {
      element.unmount();
      setUIElement && setUIElement(undefined);
      await router.push({
        pathname: '/checkout/payment',
        query: {
          payment_finalization_status: 'REQUIRES_PAYMENT_MODIFICATION',
          payment_result_status:
            'Cancelled' === resultCode ? 'PAYMENT_CANCELED' : 'PAYMENT_FAILED',
          gateway_type: AdyenPaymentForm.TYPE,
        },
      });
    }
  }, []);
};

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

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

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

      return newCart;
    },
    [
      isAuthenticated,
      cart,
      setCart,
      guestToken,
      setGuestToken,
      checkoutClient,
      getCustomerToken,
      preview,
    ]
  );
};

type UpdateFulfillmentAddressResponse = {
  cart: Cart;
  fulfillmentOptions: PricedFulfillmentOption[];
};

export const useUpdateFulfillmentAddress = ({ buildFulfillmentAddress }) => {
  const { setCart } = useCartContext();
  const { updateFulfillmentGroupAddress } = useUpdateFulfillmentGroupAddress();

  const updateContactInfoOrGenerateGuestToken =
    useUpdateContactInfoOrGenerateGuestToken();

  return useEventCallback(
    async (shippingAddress): Promise<UpdateFulfillmentAddressResponse> => {
      let newCart: Cart;

      try {
        newCart = await updateContactInfoOrGenerateGuestToken();

        const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);

        const fulfillmentAddress = buildFulfillmentAddress(shippingAddress);
        const response = await updateFulfillmentGroupAddress(
          fulfillmentAddress,
          newCart,
          shipGroupReferenceNumber
        );

        newCart = response?.cart;

        if (newCart) {
          setCart(newCart);

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

          return {
            cart: newCart,
            fulfillmentOptions,
          };
        }
      } catch (err) {
        console.error(
          'There was an error updating the checkout information',
          err
        );
      }
    },
    []
  );
};

export const useUpdateSelectedFulfillmentOption = () => {
  const { setCart } = useCartContext();
  const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems();
  const { selectFulfillmentOption } = useSelectFulfillmentOption();

  return useEventCallback(
    async (selectedShippingOptionId: string): Promise<Cart> => {
      let newCart: Cart;

      try {
        const selectedOption = find(fulfillmentOptions, [
          'description',
          selectedShippingOptionId,
        ]);

        const response = await selectFulfillmentOption(selectedOption);

        newCart = response?.cart;
        if (newCart) {
          setCart(newCart);
          pushGtmAddShipping(newCart);

          return newCart;
        }
      } catch (err) {
        console.error(
          'There was an error updating the checkout information',
          err
        );
      }
    },
    [fulfillmentOptions]
  );
};

Environment Setup

Adyen Configuration

For more information on how to enable Apple Pay, Google Pay, and/or PayPal with your Adyen merchant(s), see the following Adyen documents:

Broadleaf Configuration

To support the recording of the billing address when PayPal is used in an in-checkout context, the following property must be declared:

broadleaf:
  paymenttransaction:
    service:
      allow-supplementary-updates-while-mutability-blocked-for-payment-finalization: true

Authentication Service Data Configuration

To access the endpoint used to identify Adyen’s available payment methods (part of the advanced frontend integration used with digital wallets, express or in-checkout), the following permissions must be defined:

INSERT INTO auth.blc_security_scope ("id", "name", "open")VALUES ('-1050', 'ADYEN_PAYMENT_METHODS', 'N');
INSERT INTO auth.blc_permission_scope (id, "permission", is_permission_root, scope_id) VALUES('-1050', 'ADYEN_PAYMENT_METHODS', 'Y', '-1050');
INSERT INTO auth.blc_permission_scope (id, "permission", is_permission_root, scope_id) VALUES('-1051', 'ADYEN_PAYMENT_METHODS', 'Y', '-100');

INSERT INTO auth.blc_user_permission ("id", "archived", "last_updated", "name", "is_account_perm") VALUES('-1050', 'N', '1970-01-01 00:00:00.000', 'ALL_ADYEN_PAYMENT_METHODS', 'N');
INSERT INTO auth.blc_role_permission_xref (role_id, permission_id) VALUES ('-100', '-1050');

INSERT INTO auth.blc_client_scopes ("id", "scope") VALUES ('anonymous', 'ADYEN_PAYMENT_METHODS');
INSERT INTO auth.blc_client_permissions ("id", "permission") VALUES ('anonymous', 'ALL_ADYEN_PAYMENT_METHODS');
INSERT INTO auth.blc_client_scopes ("id", "scope") VALUES ('openapi', 'ADYEN_PAYMENT_METHODS');
INSERT INTO auth.blc_client_permissions ("id", "permission") VALUES ('openapi', 'ALL_ADYEN_PAYMENT_METHODS');

Testing Locally

For local testing digital wallets, using ngrok is especially important. Please review the instructions provided in the environment setup document.

A few additional steps for Apple Pay:

  • In the Adyen portal, enable Apple Pay & provide your ngrok-provided URL as the shop URL. Note: Adyen currently does not support updating the shop URL once Apple Pay is enabled. Therefore, for local testing, we had to create another Adyen sub-merchant account to be able to restart the Apple Pay enabling process, & provide a new ngrok URL.

  • To complete the Apple Pay interaction in a testing context, you’ll need to create an Apple developer account, a sandbox user (i.e. a test Apple ID), & add a test card to the wallet. Instructions on how to do this can be found here.