Broadleaf Microservices
  • v1.0.0-latest-prod

Stripe ACH Frontend Integration

Checkout Payment Form

import { FC, useState } from 'react';
import { useRouter } from 'next/router';
import { Form as FormikForm, Formik, FormikHelpers, Field } from 'formik';
import { get } from 'lodash';
import { useStripe } from '@stripe/react-stripe-js';
import type { PaymentIntent, StripeError } from '@stripe/stripe-js';
import {
  Address,
  PaymentRequest,
  DefaultPaymentType,
} from '@broadleaf/commerce-cart';
import type { ApiError } from '@broadleaf/commerce-core';
import { maskCardNumber } from '@broadleaf/payment-js';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';

import { useCartContext, usePaymentsContext } from '@app/cart/contexts';

import {
  usePaymentClient,
  useStripePaymentServicesClient,
} from '@app/common/contexts';
import { usePaymentAuthState } from '@app/common/hooks';

import { useCreatePaymentIntentRequest } from '@broadleaf/stripe-payment-services-react';

type PaymentFormData = Address & {
  customerEmail: string;
};

const StripeACHPaymentForm: FC = () => {
  const [showConfirmationForm, setShowConfirmationForm] =
    useState<boolean>(false);

  const { error, stripeError, onSubmit } = useHandleSubmit({
    showConfirmationForm,
    setShowConfirmationForm,
  });

  return (
    <div>
      <Formik
        initialValues={{
          ...billingAddress, // specify the billing address
          customerEmail: '',
        }}
        onSubmit={onSubmit}
        validateOnBlur
        validateOnChange={false}
      >
        {({ isSubmitting }) => (
          <FormikForm>
            {!showConfirmationForm && (
              <div className="relative ach-payment-form">
                <Field name="deposit1">
                  {({ field }) => (
                    <input
                      {...field}
                      autoComplete="email"
                      readOnly={isSubmitting}
                      type="email"
                      onChange={field.onChange}
                    />
                  )}
                </Field>
              </div>
            )}
            {showConfirmationForm && (
              <div className="relative ach-confirmation-form mandate-text">
                By clicking "Submit & Continue", you authorize ...
              </div>
            )}
            {stripeError && (
              <strong className="block my-4 text-red-600 text-lg font-normal">
                {stripeError.message}
              </strong>
            )}
            {error && (
              <strong className="block my-4 text-red-600 text-lg font-normal">
                There was an error processing your request
              </strong>
            )}
            <button type="submit" disabled={isSubmitting}>
              Submit & Continue
            </button>
          </FormikForm>
        )}
      </Formik>
    </div>
  );
};

type UseHandleSubmitResponse = {
  error?: boolean | ApiError;
  stripeError?: StripeError;
  onSubmit: (
    data: PaymentFormData,
    actions: FormikHelpers<PaymentFormData>
  ) => Promise<void>;
};

const convertAddressToBillingDetails = values => {
  const {
    fullName: name,
    phonePrimary,
    addressLine1: line1,
    addressLine2: line2,
    city,
    country,
    stateProvinceRegion: state,
    postalCode: postal_code,
  } = values;

  return {
    name,
    phone: get(phonePrimary, 'phoneNumber'),
    address: {
      line1,
      line2,
      city,
      state,
      country,
      postal_code,
    },
  };
};

type HandleSubmitProps = {
  showConfirmationForm: boolean;
  setShowConfirmationForm: (showConfirmationForm: boolean) => void;
};

const useHandleSubmit = ({
  showConfirmationForm,
  setShowConfirmationForm,
}: HandleSubmitProps): UseHandleSubmitResponse => {
  const [error, setError] = useState<boolean | ApiError>();
  const [stripeError, setStripeError] = useState<StripeError>();

  const { collectBankAccountForPayment, stripePaymentIntent } =
    useCollectBankAccountForPayment({
      setError,
      setStripeError,
      setShowConfirmationForm,
    });

  const { createPayment } = useCreatePayment({ setError });

  const onSubmit = async (
    data: PaymentFormData,
    actions: FormikHelpers<PaymentFormData>
  ): Promise<void> => {
    const { customerEmail, ...billingAddress } = data;

    // specify the amount to pay
    const amount = { amount: 11, currency: 'USD' };

    if (!stripePaymentIntent || !showConfirmationForm) {
      await collectBankAccountForPayment({
        billingAddress,
        customerEmail,
        paymentTotal: amount,
      });
    } else {
      await createPayment({
        amount,
        stripePaymentIntent,
        billingAddress,
        setSubmitting: actions.setSubmitting,
      });
    }
  };

  return { error, onSubmit, stripeError };
};

const useCollectBankAccountForPayment = ({
  setError,
  setStripeError,
  setShowConfirmationForm,
}) => {
  const stripe = useStripe();
  const authState = usePaymentAuthState();
  const stripePaymentServicesClient = useStripePaymentServicesClient();

  const { cretePaymentIntent, error: createPaymentIntentError } =
    useCreatePaymentIntentRequest({
      stripePaymentServicesClient,
      authState,
    });

  const [stripePaymentIntent, setStripePaymentIntent] =
    useState<PaymentIntent>();

  const collectBankAccountForPayment = async ({
    billingAddress,
    customerEmail,
    paymentTotal,
  }) => {
    const { clientSecret } = await cretePaymentIntent({
      paymentTotal,
    });

    if (createPaymentIntentError) {
      setError(createPaymentIntentError);
      return;
    }

    const { paymentIntent, error: collectBankAccountError } =
      await stripe.collectBankAccountForPayment({
        clientSecret,
        params: {
          payment_method_type: 'us_bank_account',
          payment_method_data: {
            billing_details: {
              ...convertAddressToBillingDetails(billingAddress),
              email: customerEmail,
            },
          },
        },
        expand: ['payment_method'],
      });

    if (collectBankAccountError) {
      console.error(collectBankAccountError.message);
      // PaymentMethod collection failed for some reason.
      setStripeError(collectBankAccountError);
    } else if (paymentIntent.status === 'requires_confirmation') {
      setStripePaymentIntent(paymentIntent);
      // show confirmation form
      setShowConfirmationForm(true);
    }
  };

  return {
    collectBankAccountForPayment,
    stripePaymentIntent,
  };
};

const useCreatePayment = ({ setError }) => {
  const router = useRouter();
  const { cart } = useCartContext();
  const { payments, refetchPayments } = usePaymentsContext();
  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();

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

  const createPayment = async ({
    amount,
    stripePaymentIntent,
    billingAddress,
    setSubmitting,
  }) => {
    let displayAttributes: Record<string, string> = {};
    let paymentName = DefaultPaymentType.ACH as string;

    const usBankAccount = stripePaymentIntent?.payment_method?.us_bank_account;

    if (usBankAccount) {
      displayAttributes = {
        bankName: usBankAccount.bank_name,
        bankAccountNumber: maskCardNumber(usBankAccount.last4),
      };

      paymentName = `${displayAttributes.bankName} | ${displayAttributes.bankAccountNumber}`;
    }

    const paymentRequest = {
      name: paymentName,
      type: DefaultPaymentType.ACH,
      gatewayType: 'STRIPE',
      amount,
      subtotal: cart.cartPricing.subtotal,
      adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
      fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
      taxTotal: cart.cartPricing.totalTax,
      isSingleUsePaymentMethod: true,
      paymentMethodProperties: {
        PAYMENT_INTENT_ID: stripePaymentIntent.id,
        ONLINE_MANDATE: 'true',
        ACCEPTED_AT: `${Math.floor(Date.now() / 1000)}`,
      },
      billingAddress,
      displayAttributes,
    } as PaymentRequest;

    let paymentSummary;

    try {
      paymentSummary = await handleSubmitPaymentInfo(paymentRequest);
    } catch (err) {
      console.error('There was an error adding payment information', err);
      setError(err);
    } finally {
      await refetchPayments();
      setSubmitting(false);

      if (paymentSummary) {
        await router.push('/checkout/review');
      }
    }
  };

  return { createPayment };
};

Save Payment for Future Use via Checkout

For authenticated customers, you can save the payment for future use at checkout. To do so, specify saveForFutureUse=true when creating the PaymentIntent, then add shouldSavePaymentForFutureUse=true to the PaymentRequest body to create the payment.

By default, it sets on_session to the setup_future_usage parameter. To change this, add savedPaymentUsage=off_session when creating the PaymentIntent.

To use a saved payment, you should specify the savedPaymentMethodId parameter in the PaymentRequest when creating a payment. In this case you don’t need to create the PaymentIntent and add the paymentMethodProperties to the PaymentRequest.

The example on how this can be implemented.

    // add saveForFutureUse when creating the PaymentIntent
    const saveForFutureUse = true;
    const { clientSecret } = await cretePaymentIntent({
      paymentTotal,
      saveForFutureUse,
    });

    // "selectedSavedPaymentMethod" is of type "SavedPaymentMethodSummary"
    let paymentMethodProperties: Record<string, string> = {};
    let displayAttributes: Record<string, string> = {};
    let paymentName = 'ACH';

    if (isEmpty(selectedSavedPaymentMethod)) {
      paymentMethodProperties = {
        PAYMENT_INTENT_ID: stripePaymentIntent.id,
        ONLINE_MANDATE: 'true',
        ACCEPTED_AT: `${Math.floor(Date.now() / 1000)}`,
      };

      const usBankAccount =
        stripePaymentIntent?.payment_method?.us_bank_account;

      if (usBankAccount) {
        displayAttributes = {
          bankName: usBankAccount.bank_name,
          bankAccountNumber: maskCardNumber(usBankAccount.last4),
        };

        paymentName = `${displayAttributes.bankName} | ${displayAttributes.bankAccountNumber}`;
      }
    } else {
      displayAttributes = selectedSavedPaymentMethod.displayAttributes;
      paymentName = selectedSavedPaymentMethod.name;
    }

    const paymentRequest = {
      name: paymentName,
      savedPaymentMethodId: selectedSavedPaymentMethod.id,
      type: 'ACH',
      gatewayType: 'STRIPE',
      amount: paymentTotal,
      subtotal: cart.cartPricing.subtotal,
      adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
      fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
      taxTotal: cart.cartPricing.totalTax,
      isSingleUsePaymentMethod: true,
      paymentMethodProperties,
      billingAddress,
      shouldSavePaymentForFutureUse: saveForFutureUse,
      displayAttributes,
    } as PaymentRequest;

Save Payment for Future Use via My Account Section

For authenticated customers, you may want to save an ACH payment method for future use, without having to charge the customer - e.g. in a My Account payment management context. With Stripe ACH, this can be done via a SetupIntent.

Before using the Stripe Setup Intent API, we recommend you to familiarize yourself with the next documentation from Stripe Save details for future payments with ACH Direct Debit

Create a SetupIntent and Collect Payment Method Details

The first step is to create the SetupIntent and collect payment method detail.

For example:

    const { createSetupIntent, error: createSetupIntentError } =
        useCreateSetupIntentRequest({
          stripePaymentServicesClient,
          authState,
    });

    const stripe = useStripe();

    const setupIntentResponse: SetupIntentResponse = await createSetupIntent({
      customerEmail,
      additionalFields: {
        PAYMENT_METHOD_TYPE: 'us_bank_account',
      },
    });

    if (createSetupIntentError) {
      console.error(createSetupIntentError);
    } else if (setupIntentResponse) {
      const billing_details = {
        ...getBillingDetails(formData),
        email: customerEmail,
      };

      // Included `clientSecret` in the returned `SetupIntentResponse` should be used to collect payment method details.

      const { setupIntent, error: collectBankAccountError } =
      await stripe.collectBankAccountForSetup({
        clientSecret: setupIntentResponse.clientSecret,
        params: {
          payment_method_type: 'us_bank_account',
          payment_method_data: {
            billing_details,
          },
        },
        expand: ['payment_method'],
      });

      if (!isEmpty(collectBankAccountError)) {
        console.error(collectBankAccountError.message);
      } else if (setupIntent.status === 'requires_payment_method') {
        // Customer canceled the hosted verification modal.
      } else if (setupIntent.status === 'requires_confirmation') {
        // Display payment method details and mandate text
        // to the customer and confirm the intent once they accept the mandate
      }
    }

    const getBillingDetails = formData => {
      const {
        fullName: name,
        phonePrimary,
        addressLine1: line1,
        addressLine2: line2,
        city,
        country,
        stateProvinceRegion: state,
        postalCode: postal_code,
      } = formData;

      return {
        name,
        phone: get(phonePrimary, 'phoneNumber'),
        address: {
          line1,
          line2,
          city,
          state,
          country,
          postal_code,
        },
      };
    };

Collect Mandate Acknowledgement and Submit

Before you can complete the SetupIntent and save the payment method details for the customer, you must obtain authorization for payment by displaying mandate terms for the customer to accept.

When the customer accepts the mandate terms, use stripe.confirmUsBankAccountSetup to complete the payment.

Create the SavedPaymentMethod if the SetupIntent status is succeeded or the next_action?.type is verify_with_microdeposits.

const paymentMethod = stripeSetupIntent.payment_method as PaymentMethod;
    const { setupIntent, error } = await stripe.confirmUsBankAccountSetup(
      stripeSetupIntent.client_secret,
      {
        payment_method: paymentMethod.id,
      }
    );

    if (error) {
      console.error(error.message);
    } else if (setupIntent.status === 'requires_payment_method') {
      // Confirmation failed
    } else if (
      setupIntent.status === 'succeeded' ||
      setupIntent.next_action?.type === 'verify_with_microdeposits'
    ) {
        let displayAttributes: Record<string, string> = {};

        const usBankAccount = stripePaymentMethod.us_bank_account;

        if (usBankAccount) {
          displayAttributes = {
            bankName: usBankAccount.bank_name,
            bankAccountNumber: `****${usBankAccount.last4}`,
          };
        }

        let nextAction: SavedPaymentMethodAction;

        if (stripeSetupIntent?.next_action?.type === 'verify_with_microdeposits') {
          nextAction = {
            actionType: 'MICRO_DEPOSIT_VERIFICATION', // (1)
          } as SavedPaymentMethodAction;
        }

        const savedPaymentMethodRequest = {
          name,
          type: 'ACH',
          billingAddress,
          owningUserType,
          owningUserId,
          gatewayType: 'STRIPE',
          defaultForOwner: false,
          displayAttributes,
          paymentMethodProperties: {
            PAYMENT_METHOD_ID: stripePaymentMethod.id,
            CUSTOMER_ID: stripeCustomerId, // (2)
          },
          visibleToChildren,
          gatewayReferenceId: stripeSetupIntent?.id, // (3)
          nextAction,
        } as CreateSavedPaymentMethodRequest;

        // create the saved payment method

        await addSavedPaymentMethod(savedPaymentMethodRequest);
    }
  1. If the SetupIntent requires verification with micro-deposit, create the SavedPaymentMethodAction with actionType=MICRO_DEPOSIT_VERIFICATION

  2. You can get the stripeCustomerId from the setupIntentResponse that is returned when you created the SetupIntent using createSetupIntent API.

  3. We have to save the SetupIntent ID as a gatewayReferenceId property to be able to read the SetupIntent if needed.

When the SavedPaymentMethod has status REQUIRES_ACTION you need to display the appropriate form for this action.

For example:

  const [stripeSetupIntent, setStripeSetupIntent] = useState<SetupIntent>();

  const { readSetupIntentInfo, error } = useReadSetupIntentInfo({
    stripePaymentServicesClient,
    authState,
  });

  useEffect(() => {
    if (
      savedPaymentMethod?.status === 'REQUIRES_ACTION' &&
      savedPaymentMethod?.nextAction?.actionType ===
        'MICRO_DEPOSIT_VERIFICATION' &&
      !stripeSetupIntent
    ) {
      const setupIntentId = savedPaymentMethod.gatewayReferenceId;

      const setupIntentResponse = await readSetupIntentInfo(setupIntentId);

      if (setupIntentResponse) {
        const { setupIntent, error: stripeError } = await stripe.retrieveSetupIntent(setupIntentResponse.clientSecret);

        if (stripeError) {
          console.error(stripeError.message);
        } else {
          setStripeSetupIntent(setupIntent);
        }
      } else if (error) {
        console.error('Error ...');
      }
    }
  }, [...]);


  return (
      <div>
        {savedPaymentMethod?.status === 'REQUIRES_ACTION' &&
        savedPaymentMethod?.nextAction?.actionType ===
          'MICRO_DEPOSIT_VERIFICATION' && (
          <ACHMicroDepositVerificationForm savedPaymentMethod={savedPaymentMethod} stripeSetupIntent={stripeSetupIntent} />
        )}
        ...
      </div>
  )

You can use the Stripe hosted micro-deposit verification form. To do so show the URL from stripeSetupIntent.next_action.verify_with_microdeposits.hosted_verification_url. When customer opens this page and enters the data to verify the payment method Stripe sends the webhook event setup_intent.succeeded or setup_intent.succeeded. Such events are handled by com.broadleafcommerce.paymenttransaction.service.webhook.SavedPaymentMethodWebhookService. By default, this service uses com.broadleafcommerce.payment.service.gateway.webhooks.StripeSavedPaymentMethodWebhookHandler to validate the event and convert it to com.broadleafcommerce.paymentgateway.domain.SavedPaymentMethodSetupResult. SavedPaymentMethodSetupResult contains the information about the event and the gatewayReferenceId (SetupIntent ID) that is used to retrieve the SavedPaymentMethod and update its status based on the event.

To use the webhook handler don’t forget to add the webhook endpoint - /api/payment/webhooks/saved-payment-method/STRIPE

Custom Micro-deposit Verification Page (Optional)

The following example contains the custom page to verify the SetupIntent with micro-deposit.

import React, { FC } from 'react';
import { range, toUpper } from 'lodash';
import { useRouter } from 'next/router';
import { Form as FormikForm, Formik } from 'formik';

import { useStripe } from '@stripe/react-stripe-js';

import {
  SavedPaymentMethodSummary,
  UpdateSavedPaymentMethodRequest,
} from '@broadleaf/commerce-customer';

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

import { usePaymentAuthState, usePaymentOwner } from '@app/common/hooks';
import { InputField } from '@app/common/components';
import {
  usePreviewOptions,
  useSavedPaymentMethodClient,
} from '@app/common/contexts';

import { SetupIntent } from '@stripe/stripe-js';

type Props = {
  savedPaymentMethod: SavedPaymentMethodSummary;
  stripeSetupIntent: SetupIntent;
};

export const ACHMicroDepositVerificationForm: FC<Props> = ({
  savedPaymentMethod,
  stripeSetupIntent,
}) => {
  const { replace, reload } = useRouter();

  const stripe = useStripe();

  const previewOptions = usePreviewOptions();
  const { owningUserType, owningUserId } = usePaymentOwner();
  const savedPaymentMethodClient = useSavedPaymentMethodClient();

  const authState = usePaymentAuthState();
  const { updateSavedPaymentMethod } = useUpdateSavedPaymentMethod({
    authState,
    savedPaymentMethodClient,
    previewOptions,
  });
  const onSubmit = async values => {
    const {
      descriptorCode2,
      descriptorCode3,
      descriptorCode4,
      descriptorCode5,
    } = values;

    const descriptorCode = toUpper(
      `SM${descriptorCode2}${descriptorCode3}${descriptorCode4}${descriptorCode5}`
    );

    const verificationResponse = await stripe.verifyMicrodepositsForSetup(
      stripeSetupIntent.client_secret,
      {
        descriptor_code: descriptorCode,
      }
    );

    const setupIntent =
      verificationResponse.setupIntent ||
      verificationResponse.error.setup_intent;

    if (setupIntent.status === 'succeeded') {
      const updateSavedPaymentMethodRequest = {
        id: savedPaymentMethod.id,
        nextAction: {
          ...savedPaymentMethod.nextAction,
          status: 'SUCCESS',
        },
        version: savedPaymentMethod.version,
      } as UpdateSavedPaymentMethodRequest;
      await updateSavedPaymentMethod(
        owningUserType,
        owningUserId,
        updateSavedPaymentMethodRequest
      );

      replace('/my-account/payments');
    } else {
      const errorCode = setupIntent.last_setup_error?.code;

      if (
        errorCode ===
          'payment_method_microdeposit_verification_attempts_exceeded' ||
        errorCode === 'payment_method_microdeposit_verification_timeout'
      ) {
        const updateSavedPaymentMethodRequest = {
          id: savedPaymentMethod.id,
          nextAction: {
            ...savedPaymentMethod.nextAction,
            status: 'FAILURE',
            statusDetails: setupIntent.last_setup_error?.message,
          },
          version: savedPaymentMethod.version,
        } as UpdateSavedPaymentMethodRequest;
        await updateSavedPaymentMethod(
          owningUserType,
          owningUserId,
          updateSavedPaymentMethodRequest
        );

        reload();
      }

      console.log(setupIntent.last_setup_error?.message);
    }
  };

  return (
    <section className="flex flex-col">
      <header className="mb-4">
        <h4 className="mb-6 mt-4 text-center text-2xl font-bold">
          Enter the 6-digit code from your bank statement to complete payment
        </h4>
      </header>
      <div className="flex justify-center">
        <Formik
          initialValues={{
            descriptorCode0: 'S',
            descriptorCode1: 'M',
            descriptorCode2: '',
            descriptorCode3: '',
            descriptorCode4: '',
            descriptorCode5: '',
          }}
          onSubmit={onSubmit}
        >
          {({ isSubmitting }) => (
            <FormikForm className="flex flex-col">
              <p className="my-2 max-w-xs text-justify text-sm">
                Stripe deposited $0.01 to your account. To complete your
                payment, enter the 6-digit code starting with "SM" from deposit
              </p>
              <p className="flex self-center my-2 text-sm">Enter Code</p>
              <div className="flex flex-nowrap self-center mb-2 h-20">
                {range(6).map(n => (
                  <InputField
                    key={n}
                    className="mx-1 w-12"
                    inputClassName="uppercase"
                    name={`descriptorCode${n}`}
                    readOnly={isSubmitting || n === 0 || n === 1}
                    required
                    maxLength={1}
                  />
                ))}
              </div>

              <button type="submit">Verify</button>
              <p className="my-2 max-w-xs text-justify text-sm">
                Can't find it? Check back in 1-2 days to try again with another
                deposit.
              </p>
            </FormikForm>
          )}
        </Formik>
      </div>
    </section>
  );
};

When you are using the custom micro-deposit verification page. Stripe doesn’t send the webhook event, and you have to update the SavedPaymentMethod via API.