Broadleaf Microservices
  • v1.0.0-latest-prod

White-glove Experience for Customers

By default, Stripe provides a hosted micro-deposit verification page & sends misc. ACH-related emails to the customer. While this provides a path to quickly complete integrations, it provides little to no control over the look & feel of these interactions. To provide a more white-glove experience for your customers, Stripe allows these interactions to be taken over by the integrator. Within the Broadleaf context, this means providing the necessary data & plumbing to allow our clients to create a custom micro-deposit verification page and send custom emails via the NotificationService.

Adding a Custom Micro-deposit Verification Page

Backend Configuration

Add the following configuration:

broadleaf:
  stripe:
    micro-deposit-verification-url: https://heatclinic.localhost:8456/checkout/stripe-ach-microdeposit-verification (1)
    payment-intent-access-token-secret: token_secret (2)
  1. The full URL to the custom micro-deposit verification page

  2. The secret key that is used to generate the payment intent access token

By default, the PaymentIntent id and access token will be added as a parameters to this URL - {microDepositVerificationUrl}?paymentIntentId={paymentIntentId}&accessToken={accessToken}. This id and token should be used by the frontend to retrieve the clientSecret (GET /api/payment/stripe/payment-intent/{paymentIntentId} --header 'X-Payment-Intent-Access-Token: accessToken') that then is used to get the Stripe PaymentIntent object on the client side.

The access token was added because if the custom page is used to validate the payment with micro-deposit, it is required to retrieve the PaymentIntent on the client side using the client secret. Storing the client secret is not secure, so we generate the validation URL with the payment intent ID and access token. These values should be used to get the client secret using the server-side API.

Note
The token secret should be stored securely, as you would with the Stripe API credentials.

The expectation is that this URL is surfaced to the customer via the order confirmation page & email after checkout has been submitted.

Frontend Example

import React, { useEffect, useState } from 'react';
import { GetServerSidePropsContext } from 'next';
import { get, range, toUpper } from 'lodash';

import { useStripe } from '@stripe/react-stripe-js';
import { PaymentIntent, StripeError } from '@stripe/stripe-js';
import { Form, Formik, Field } from 'formik';

import { useStripePaymentServicesClient } from '@app/common/contexts';

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

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

type Props = {
  paymentIntentId: string;
  accessToken: string;
};

type ServerSideProps = {
  props: Props;
};

export default function StripeAchMicroDepositVerification(
  props: Props
): JSX.Element {
  const { paymentIntentId, accessToken } = props;

  const stripe = useStripe();

  const [clientSecret, setClientSecret] = useState<string>();
  const [paymentIntent, setPaymentIntent] = useState<PaymentIntent>();
  const [stripeError, setStripeError] = useState<StripeError>();

  const stripePaymentServicesClient = useStripePaymentServicesClient();
  const authState = usePaymentAuthState();
  const { readPaymentIntentInfo, error: readPaymentIntentError } =
    useReadPaymentIntentInfo({
      stripePaymentServicesClient,
      authState,
    });

  useEffect(() => {
    if (paymentIntentId && !clientSecret) {
      readPaymentIntentInfo(paymentIntentId, accessToken)
        .then(paymentIntentResponse => {
          setClientSecret(paymentIntentResponse.clientSecret);
        })
        .catch(() => {
          console.error('There was an error processing your request. Please check your info and try again.');
        });
    }
  }, [paymentIntentId, accessToken, readPaymentIntentInfo, clientSecret]);

  useEffect(() => {
    if (stripe && clientSecret && !paymentIntent) {
      stripe
        .retrievePaymentIntent(clientSecret)
        .then(stripeResponse => {
          if (stripeResponse.error) {
            setStripeError(stripeResponse.error);
          } else {
            setPaymentIntent(stripeResponse.paymentIntent);
          }
        })
        .catch(() => {
          console.error('There was an error processing your request. Please check your info and try again.');
        });
    }
  }, [stripe, clientSecret, paymentIntent]);

  const handleSubmit = useHandleSubmit({
    clientSecret,
    setPaymentIntent,
    setStripeError,
  });

  if (!stripe || !paymentIntent) {
    return <div>'Loading ...'</div>;
  }

  if (paymentIntent?.last_payment_error?.message) {
    return (
      <div className="flex flex-col justify-center">
        <strong className="block mx-auto my-4 max-w-md text-center text-red-600 text-lg font-normal">
          {paymentIntent.last_payment_error.message}
        </strong>
        <div className="flex justify-center mb-8">
          <a href="/">Continue Shopping</a>
        </div>
      </div>
    );
  }

  if (
    paymentIntent.status !== 'requires_action' ||
    paymentIntent.next_action?.type !== 'verify_with_microdeposits'
  ) {
    return (
      <div>
        <h2 className="mb-6 mt-4 text-center text-4xl font-bold">
          Thank you, your bank account has been verified!
        </h2>
        <div className="flex justify-center mb-8">
          <a href="/">Continue Shopping</a>
        </div>
      </div>
    );
  }

  const microDepositType = get(
    paymentIntent,
    'next_action.verify_with_microdeposits.microdeposit_type'
  );

  let initialValues;

  if (microDepositType === 'descriptor_code') {
    initialValues = {
      descriptorCode0: 'S',
      descriptorCode1: 'M',
    };
  }

  return (
    <main className="container mx-auto px-4 py-8 xl:px-0">
      <section className="flex flex-col">
        <header className="mb-4">
          {microDepositType === 'descriptor_code' && (
            <h2 className="mb-6 mt-4 text-center text-4xl font-bold">
              Enter the 6-digit code from your bank statement to complete
              payment
            </h2>
          )}
          {microDepositType !== 'descriptor_code' && (
            <h2 className="mb-6 mt-4 mx-auto max-w-lg text-center text-2xl font-bold">
              Enter a two positive integers, in cents, equal to the values of
              the micro-deposits sent to your bank account
            </h2>
          )}
        </header>
        <div className="flex justify-center">
          <Formik
            initialValues={initialValues}
            onSubmit={async (values, actions) =>
              handleSubmit(values, actions, microDepositType)
            }
          >
            {({ isSubmitting }) => (
              <Form className="flex flex-col">
                {microDepositType === 'descriptor_code' && (
                  <>
                    <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 => (
                        <Field name={`descriptorCode${n}`}>
                          {({ field }) => (
                            <input
                              {...field}
                              className="uppercase"
                              maxLength={1}
                              readOnly={isSubmitting || n === 0 || n === 1}
                              type="text"
                              onChange={field.onChange}
                            />
                          )}
                        </Field>
                      ))}
                    </div>
                  </>
                )}
                {microDepositType !== 'descriptor_code' && (
                  <div>
                    <InputField
                      className="mx-2 my-2"
                      name="deposit0"
                      label={formatMessage(
                        messages.microDepositAmountsInputLabel,
                        {
                          valueNumber: 1,
                        }
                      )}
                      readOnly={isSubmitting}
                      required
                      maxLength={2}
                      minLength={2}
                    />
                    <InputField
                      className="mx-2 my-2"
                      name="deposit1"
                      label={formatMessage(
                        messages.microDepositAmountsInputLabel,
                        {
                          valueNumber: 2,
                        }
                      )}
                      readOnly={isSubmitting}
                      required
                      maxLength={2}
                      minLength={2}
                    />
                  </div>
                )}

                {(stripeError || readPaymentIntentError) && (
                  <strong className="block my-4 max-w-xs text-center text-red-600 text-lg font-normal">
                    {stripeError?.message ||
                      'There was an error processing your request'}
                  </strong>
                )}

                <button type="submit" disabled={isSubmitting}>
                  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>
              </Form>
            )}
          </Formik>
        </div>
      </section>
    </main>
  );
}

const useHandleSubmit = ({
  clientSecret,
  setPaymentIntent,
  setStripeError,
}) => {
  const stripe = useStripe();

  return async (values, actions, microDepositType) => {
    let data;

    if (microDepositType === 'descriptor_code') {
      const {
        descriptorCode2,
        descriptorCode3,
        descriptorCode4,
        descriptorCode5,
      } = values;
      const descriptorCode = toUpper(
        `SM${descriptorCode2}${descriptorCode3}${descriptorCode4}${descriptorCode5}`
      );
      data = {
        descriptor_code: descriptorCode,
      };
    } else {
      data = {
        amounts: [values.deposit0, values.deposit1],
      };
    }

    const stripeResponse = await stripe.verifyMicrodepositsForPayment(
      clientSecret,
      data
    );

    if (stripeResponse.error) {
      setStripeError(stripeResponse.error);
    } else {
      setPaymentIntent(stripeResponse.paymentIntent);
    }

    actions.setSubmitting(false);
  };
};

export async function getServerSideProps({
  query,
  res,
}: GetServerSidePropsContext): Promise<ServerSideProps> {
  if (res.statusCode > 299) {
    // if we've already encountered an error or redirect, skip the normal activities
    return {
      props: { paymentIntentId: '', accessToken: '' },
    };
  }
  const { paymentIntentId, accessToken } = query;

  return {
    props: {
      paymentIntentId: paymentIntentId as string,
      accessToken: accessToken as string,
    },
  };
}

Custom Messaging and Emails

When ACH transactions are attempted with Stripe, there will always be a several-day delay between the initiation of the transaction & when the transaction is processed. During that time, it’s important to communicate the state of the order & payment processing to the customer. Additionally, if micro-deposit verification is required, it’s important to clearly highlight to the customer that additional action is required from them to successfully process the transaction.

By default, Stripe ACH transactions with Broadleaf include the following touch-points with the customer during checkout, or after checkout has been submitted:

  • ACH mandate approval email sent by Stripe

  • Email sent by Broadleaf acknowledging the order submission, but highlighting that we’re awaiting payment results to finalize the checkout

  • Micro-deposit verification email sent by Stripe

  • Order confirmation email sent by Broadleaf if/when the ACH transaction succeeds

  • Payment failure email sent by Broadleaf if/when the ACH transaction fails

To create more of a white-glove experience, all of these emails can be customized. The Stripe-sent emails can be disabled via the Stripe Dashboard email settings section, & replaced by emails sent via Broadleaf. Furthermore, all emails sent via Broadleaf can be completely customized via overriding the default NotificationService templates.

Relevant NotificationService Email Templates:

  • stripe_ach_mandate_confirmation.html

  • awaiting_payment_results.html

    • NOTE: If micro-deposit verification is required, we expect the verification url to be communicated via this email.

  • pending_payment_failed.html

  • order_confirmation.html

Custom ACH Mandate Confirmation Emails

When customizing the ACH Mandate Confirmation emails, make sure to review the Stripe documentation on this topic. The PaymentCustomerNotification payload coming from PaymentTransactionServices & the BLC-Stripe payment module includes the recommended fields from the ACH payment method - i.e. authorization date, account holder name, financial institution, routing number, & the last four digits of the account number. Additionally, this payload identifies if the ACH payment method is being saved for future use so that you know whether to include both portions of Stripe’s provided mandate text or not.

To declare the intention of sending custom mandate approval emails, the following property should be declared with your application that contains PaymentTransactionServices:

broadleaf:
  stripe:
    send-custom-mandate-email: true

To assist with the display of the mandate text in your email template, declare the following property with your application that contains NotificationServices:

broadleaf:
  notification:
    message:
      variables-by-application-id:
        'your_application_id':
          business-name: 'Your Business Name'
Note
  • See usages of the notification.email.ach-payment.mandate.text & notification.email.ach-payment.mandate.future-use.text message properties to get a better sense of how this value is used for the out-of-box template.

  • The business name can be declared per-application or per-tenant. To declare this value at the tenant level, use: broadleaf.notification.message.variables-by-tenant-id.{your_tenant_id}.business-name.

  • The custom notifications will be sent to the customers when the ACH payment transaction is executed, or when the SetupIntent is confirmed and the saved payment method is created.