import React, { FC, useState } from 'react';
import classNames from 'classnames';
import {
capitalize,
find,
indexOf,
isEmpty,
map,
padStart,
reduce,
} from 'lodash';
import {
ClickResolveDetails,
ConfirmationToken,
StripeExpressCheckoutElementClickEvent,
StripeExpressCheckoutElementShippingAddressChangeEvent,
StripeExpressCheckoutElementShippingRateChangeEvent,
} from '@stripe/stripe-js';
import {
ExpressCheckoutElement,
useElements,
useStripe,
} from '@stripe/react-stripe-js';
import messages from '@app/checkout/messages';
import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import {
useCallbackFormatAmountInMinorUnits,
useGetPaymentAmount,
useGetPaymentCallbackUrl,
useHandleSubmitCart,
useUpdateFulfillmentGroup,
} from '@app/checkout/hooks';
import { StripePaymentForm } from '@app/checkout/components';
import { MonetaryAmount } from '@broadleaf/commerce-browse';
import {
useEventCallback,
useFormatMessage,
usePaymentAuthState,
} from '@app/common/hooks';
import {
Address,
Cart,
CheckoutClientCallOptions,
DefaultFulfillmentType,
DefaultPaymentType,
FulfillmentAddress,
GuestTokenResponse,
PaymentRequest,
PaymentSummary,
Phone,
PricedFulfillmentOption,
UpdateFulfillmentGroupRequest,
} from '@broadleaf/commerce-cart';
import { useAuth } from '@broadleaf/auth-react';
import { useCsrContext } from '@app/csr/context';
import { areAddressesEquivalent, maskCardNumber } from '@broadleaf/payment-js';
import {
hasAnyVirtualFulfillmentGroup,
pushGtmAddPayment,
pushGtmAddShipping,
} from '@app/common/utils';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';
import {
useCheckoutClient,
usePaymentClient,
usePreviewOptions,
} from '@app/common/contexts';
import {
useFetchFulfillmentOptionsForCartItems,
useRepriceCart,
useSelectFulfillmentOption,
useUpdateFulfillmentGroupAddress,
} from '@app/cart/hooks';
import {
getShipGroup,
getShipGroupPriceFulfillmentOption,
getShipGroupReferenceNumber,
} from '@app/cart/utils';
import { useGetCustomerAccessToken } from '@app/auth/hooks';
import {
BillingDetails,
ExpressCheckoutPartialAddress,
ShippingAddress,
StripeExpressCheckoutElementConfirmEvent,
StripeExpressCheckoutElementOptions,
StripeExpressCheckoutElementReadyEvent,
} from '@stripe/stripe-js/dist/stripe-js/elements/express-checkout';
import { PaymentMethod } from '@stripe/stripe-js/dist/api/payment-methods';
type Props = {
className?: string;
enabledPaymentTypes: Array<string>;
setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void;
};
export const StripeWalletExpressCheckout: FC<Props> = ({
className,
setCanUseExpressCheckout,
enabledPaymentTypes,
}) => {
const formatMessage = useFormatMessage();
const cartState = useCartContext();
const { resolving: resolvingCart, cart } = cartState;
const fulfillmentGroup = getShipGroup(cart);
const fulfillmentType = fulfillmentGroup?.type;
const isVirtualFulfillment =
fulfillmentType === DefaultFulfillmentType.VIRTUAL;
const [error, setError] = useState(null);
const onShippingAddressChange = useOnShippingAddressChange(setError);
const onShippingOptionChange = useOnShippingOptionChange(setError);
const onPaymentMethod = useOnPaymentMethod(setError);
const options = useStripeExpressElementOptions(
isVirtualFulfillment,
enabledPaymentTypes
);
const handleStartCheckout = useHandleStartCheckout();
const onReady = useHandleOnReady(
setCanUseExpressCheckout,
enabledPaymentTypes
);
if (resolvingCart || !cart) {
return null;
}
return (
<div className={classNames(className, 'stripe-express-checkout')}>
<ExpressCheckoutElement
onConfirm={onPaymentMethod}
onShippingAddressChange={onShippingAddressChange}
onShippingRateChange={onShippingOptionChange}
options={options}
onClick={handleStartCheckout}
onReady={onReady}
/>
{error && (
<strong className="block my-4 text-red-600 text-lg font-normal">
{formatMessage(messages.genericError)}
</strong>
)}
</div>
);
};
const useStripeExpressElementOptions = (
isVirtualFulfillment: boolean,
enabledPaymentTypes: Array<string>
): StripeExpressCheckoutElementOptions => {
return {
buttonType: {
googlePay: 'buy',
applePay: 'book',
paypal: 'buynow',
},
buttonTheme: {
applePay: 'black',
},
paymentMethods: {
googlePay:
indexOf(enabledPaymentTypes, DefaultPaymentType.GOOGLE_PAY) !== -1
? 'always'
: 'never',
applePay:
indexOf(enabledPaymentTypes, DefaultPaymentType.APPLE_PAY) !== -1
? 'always'
: 'never',
},
buttonHeight: 55,
emailRequired: true,
phoneNumberRequired: true,
shippingAddressRequired: !isVirtualFulfillment,
};
};
const useHandleOnReady = (
setCanUseExpressCheckout: (canUseExpressCheckout: boolean) => void,
enabledPaymentTypes
) => {
return useEventCallback(
(event: StripeExpressCheckoutElementReadyEvent) => {
const availablePaymentMethods = event.availablePaymentMethods;
if (
(availablePaymentMethods.googlePay &&
indexOf(enabledPaymentTypes, DefaultPaymentType.GOOGLE_PAY) !== -1) ||
(availablePaymentMethods.applePay &&
indexOf(enabledPaymentTypes, DefaultPaymentType.APPLE_PAY) !== -1)
) {
setCanUseExpressCheckout(true);
}
},
[setCanUseExpressCheckout, enabledPaymentTypes]
);
};
const useHandleStartCheckout = () => {
const { cart } = useCartContext();
const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();
return async (event: StripeExpressCheckoutElementClickEvent) => {
const lineItems = map(cart.cartItems, cartItem => {
const isMerch =
cartItem.internalAttributes.productType === 'MERCHANDISING_PRODUCT';
const itemTotal = isMerch
? cartItem.totalWithDependentItems
: cartItem.total;
const cartItemAmount = formatAmountInMinorUnits(itemTotal);
const label = `${cartItem.name} (${cartItem.quantity})`;
return {
amount: cartItemAmount,
name: label,
};
});
event.resolve({ lineItems: lineItems } as ClickResolveDetails);
};
};
const useOnShippingAddressChange = setError => {
const { setCart } = useCartContext();
const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();
const getPaymentAmount = useGetPaymentAmount({
gatewayType: StripePaymentForm.TYPE,
});
const { updateFulfillmentGroupAddress } = useUpdateFulfillmentGroupAddress();
const buildShippingOptions = useBuildShippingOptions();
const updateContactInfoOrGenerateGuestToken =
useUpdateContactInfoOrGenerateGuestToken(setError);
const { isAuthenticated, user } = useAuth();
const elements = useElements();
return async (
event: StripeExpressCheckoutElementShippingAddressChangeEvent
) => {
let newCart: Cart;
try {
newCart = await updateContactInfoOrGenerateGuestToken(
isAuthenticated ? user?.email_address : undefined
);
const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
const fulfillmentAddress = buildFulfillmentAddress(event.address);
const response = await updateFulfillmentGroupAddress(
fulfillmentAddress,
newCart,
shipGroupReferenceNumber
);
newCart = response?.cart;
if (newCart) {
setCart(newCart);
const fulfillmentOptions =
response.fulfillmentOptionResponse.groupFulfillmentOptions[
shipGroupReferenceNumber
];
const shippingOptions = buildShippingOptions(
fulfillmentOptions,
newCart
);
const paymentAmount = getPaymentAmount(newCart);
elements.update({
amount: formatAmountInMinorUnits(paymentAmount),
});
event.resolve({ shippingRates: shippingOptions });
} else {
event.reject();
}
} catch (err) {
event.reject();
setError(err);
console.error(
'There was an error updating the checkout information',
err
);
}
};
};
const useOnShippingOptionChange = setError => {
const { cart, setCart } = useCartContext();
const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();
const getPaymentAmount = useGetPaymentAmount({
gatewayType: StripePaymentForm.TYPE,
});
const { fulfillmentOptions } = useFetchFulfillmentOptionsForCartItems(
isEmpty(getShipGroup(cart)?.address)
);
const { selectFulfillmentOption } = useSelectFulfillmentOption();
const buildShippingOptions = useBuildShippingOptions();
const elements = useElements();
return async (event: StripeExpressCheckoutElementShippingRateChangeEvent) => {
let newCart: Cart;
try {
const stripeShippingOption = event.shippingRate;
const selectedOption = find(fulfillmentOptions, [
'description',
stripeShippingOption.id,
]);
const response = await selectFulfillmentOption(selectedOption);
newCart = response?.cart;
if (newCart) {
setCart(newCart);
const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
const fulfillmentOptions =
response.fulfillmentOptionResponse.groupFulfillmentOptions[
shipGroupReferenceNumber
];
const shippingOptions = buildShippingOptions(
fulfillmentOptions,
newCart
);
const paymentAmount = getPaymentAmount(newCart);
elements.update({
amount: formatAmountInMinorUnits(paymentAmount),
});
event.resolve({ shippingRates: shippingOptions });
pushGtmAddShipping(newCart);
} else {
event.reject();
}
} catch (err) {
event.reject();
setError(err);
console.error(
'There was an error updating the checkout information',
err
);
}
};
};
const useOnPaymentMethod = setError => {
const { cart, setCart } = useCartContext();
const { payments } = usePaymentsContext();
const existingPayment = payments?.content?.filter(
p => p.gatewayType === StripePaymentForm.TYPE
)[0];
const { repriceCart, error: repriceError } = useRepriceCart();
const paymentClient = usePaymentClient();
const authState = usePaymentAuthState();
const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
authState,
payments,
ownerId: cart?.id,
owningUserEmailAddress: cart.emailAddress,
paymentClient,
multiplePaymentsAllowed: true,
rejectOnError: true,
});
const { onSubmit: submitCart } = useHandleSubmitCart();
const getPaymentAmount = useGetPaymentAmount({
gatewayType: StripePaymentForm.TYPE,
});
const updateContactInfoOrGenerateGuestToken =
useUpdateContactInfoOrGenerateGuestToken(setError);
const { updateFulfillmentGroup } = useUpdateFulfillmentGroup();
const stripe = useStripe();
const elements = useElements();
const getPaymentCallbackUrl = useGetPaymentCallbackUrl();
return async (event: StripeExpressCheckoutElementConfirmEvent) => {
let newCart: Cart;
let paymentSummary: PaymentSummary;
const fulfillmentGroup = getShipGroup(cart);
const fulfillmentType = fulfillmentGroup?.type;
const isVirtualFulfillment =
fulfillmentType === DefaultFulfillmentType.VIRTUAL;
try {
newCart = await updateContactInfoOrGenerateGuestToken(
event.billingDetails.email
);
if (!isVirtualFulfillment) {
const shipGroupReferenceNumber = getShipGroupReferenceNumber(newCart);
const shippingAddress = buildShippingAddress(
event.shippingAddress,
event.billingDetails
);
const currentShippingAddress = getShipGroup(newCart)?.address;
const addressesEquivalent = areAddressesEquivalent(
shippingAddress,
currentShippingAddress
);
if (!addressesEquivalent) {
const request = {
address: shippingAddress,
} as UpdateFulfillmentGroupRequest;
newCart = await updateFulfillmentGroup(
shipGroupReferenceNumber,
request,
newCart
);
if (newCart) {
setCart(newCart);
} else {
event.paymentFailed({ reason: 'fail' });
setError(true);
return;
}
}
}
const { error: submitError } = await elements.submit();
if (submitError) {
setError(submitError.message);
return;
}
const redirectURL = getPaymentCallbackUrl({
gatewayType: StripePaymentForm.TYPE,
});
const { error, confirmationToken } = await stripe.createConfirmationToken(
{
elements,
params: {
payment_method_data: {
billing_details: event.billingDetails,
},
return_url: redirectURL,
},
}
);
if (error) {
// This point is only reached if there's an immediate error when
// creating the ConfirmationToken. Show the error to your customer (for example, payment details incomplete)
setError(error.message);
event.paymentFailed({ reason: 'fail' });
return;
}
const paymentRequest = buildPaymentRequest(
event,
getPaymentAmount(newCart),
newCart,
confirmationToken
);
paymentSummary = await handleSubmitPaymentInfo(
paymentRequest,
existingPayment?.paymentId
);
if (paymentSummary) {
// recalculate the taxes for the virtual fulfillment because it requires the billing payment address
if (hasAnyVirtualFulfillmentGroup(newCart)) {
newCart = await repriceCart();
if (!newCart || repriceError) {
// there was an error
setError(repriceError || true);
return;
} else {
setCart(newCart);
}
}
pushGtmAddPayment(newCart, paymentSummary);
await submitCart();
} else {
event.paymentFailed({ reason: 'fail' });
}
} catch (err) {
setError(err);
event.paymentFailed({ reason: 'fail' });
console.error('An error occurred while processing the request', err);
}
};
};
const buildPaymentRequest = (
event: StripeExpressCheckoutElementConfirmEvent,
paymentAmount: MonetaryAmount,
cart: Cart,
confirmationToken: ConfirmationToken
): PaymentRequest => {
const paymentMethodProperties = {
CONFIRMATION_TOKEN_ID: confirmationToken.id,
};
const stripeBillingDetails = event.billingDetails;
const stripeCard: PaymentMethod.Card =
confirmationToken.payment_method_preview.card;
const phonePrimary: Phone = stripeBillingDetails?.phone
? {
phoneNumber: stripeBillingDetails.phone,
}
: null;
const billingAddress = {
fullName: stripeBillingDetails?.name,
emailAddress: stripeBillingDetails?.email,
addressLine1: stripeBillingDetails?.address?.line1,
addressLine2: stripeBillingDetails?.address?.line2,
city: stripeBillingDetails?.address?.city,
stateProvinceRegion: stripeBillingDetails?.address?.state,
country: stripeBillingDetails?.address?.country,
postalCode: stripeBillingDetails?.address?.postal_code,
phonePrimary,
} as Address;
const displayAttributes = {
creditCardType: stripeCard.brand.toUpperCase(),
creditCardNumber: maskCardNumber(stripeCard.last4),
creditCardExpDateMonth: padStart(`${stripeCard.exp_month}`, 2, '0'),
creditCardExpDateYear: `${stripeCard.exp_year}`,
};
const paymentName = `${capitalize(
confirmationToken.payment_method_preview.card.wallet
)} | ${displayAttributes.creditCardType} | ${
displayAttributes.creditCardNumber
} ${displayAttributes.creditCardExpDateMonth}/${
displayAttributes.creditCardExpDateYear
}`;
let paymentType;
if (event.expressPaymentType === 'apple_pay') {
paymentType = DefaultPaymentType.APPLE_PAY;
} else if (event.expressPaymentType === 'google_pay') {
paymentType = DefaultPaymentType.GOOGLE_PAY;
}
return {
owningUserName: stripeBillingDetails.name,
owningUserEmailAddress: stripeBillingDetails.email,
name: paymentName,
type: paymentType,
gatewayType: StripePaymentForm.TYPE,
amount: paymentAmount,
subtotal: cart.cartPricing.subtotal,
adjustmentsTotal: cart.cartPricing.adjustmentsTotal,
fulfillmentTotal: cart.cartPricing.fulfillmentTotal,
taxTotal: cart.cartPricing.totalTax,
isSingleUsePaymentMethod: true,
billingAddress,
paymentMethodProperties,
};
};
const useUpdateContactInfoOrGenerateGuestToken = setError => {
const { isAuthenticated } = useAuth();
const { cart, setCart, guestToken, setGuestToken } = useCartContext();
const { csrAnonymous } = useCsrContext();
const checkoutClient = useCheckoutClient();
const getCustomerToken = useGetCustomerAccessToken();
const preview = usePreviewOptions();
return async (emailAddress?: string): Promise<Cart> => {
let newCart = cart;
try {
if (emailAddress && (guestToken || (isAuthenticated && !csrAnonymous))) {
const accessToken = await getCustomerToken();
newCart = await checkoutClient.updateContactInfoInCart(
cart.id,
{ emailAddress },
{
accessToken,
guestToken: guestToken?.tokenString,
preview,
version: cart.version,
}
);
} else if (!guestToken) {
const accessToken = await getCustomerToken();
const options = {
accessToken,
guestToken: guestToken?.tokenString,
preview,
version: cart.version,
} as CheckoutClientCallOptions;
let response: GuestTokenResponse;
if (emailAddress) {
response = await checkoutClient.checkoutAsGuest(
cart.id,
{ emailAddress },
options
);
} else {
response = await checkoutClient.generateGuestToken(cart.id, options);
}
newCart = response.cart;
setGuestToken(response.token);
}
if (newCart) {
setCart(newCart);
}
} catch (err) {
console.error(
'There was an error updating the checkout information',
err
);
setError(err);
return Promise.reject(err);
}
return newCart;
};
};
const useBuildShippingOptions = () => {
const formatAmountInMinorUnits = useCallbackFormatAmountInMinorUnits();
return (fulfillmentOptions: Array<PricedFulfillmentOption>, cart: Cart) => {
const currentPricedFulfillmentOption =
getShipGroupPriceFulfillmentOption(cart);
return reduce(
fulfillmentOptions,
(result, fulfillmentOption) => {
const shippingOption = {
id: fulfillmentOption.description,
displayName: fulfillmentOption.description,
deliveryEstimate: {
minimum: {
unit: 'day',
value: fulfillmentOption.estimatedMinDaysToFulfill,
},
maximum: {
unit: 'day',
value: fulfillmentOption.estimatedMaxDaysToFulfill,
},
},
amount: formatAmountInMinorUnits(fulfillmentOption.price),
};
if (shippingOption.id === currentPricedFulfillmentOption?.description) {
// add the selected fulfillment option to the head
// The first shipping option listed appears in the browser payment interface as the default option
result = [shippingOption, ...result];
} else {
result.push(shippingOption);
}
return result;
},
[]
);
};
};
const buildFulfillmentAddress = (
stripeShippingAddress: ExpressCheckoutPartialAddress
): FulfillmentAddress => {
return {
address1: undefined,
address2: undefined,
city: stripeShippingAddress.city,
region: stripeShippingAddress.state,
country: stripeShippingAddress.country,
postalCode: stripeShippingAddress.postal_code,
};
};
const buildShippingAddress = (
stripeShippingAddress: ShippingAddress,
stripeBillingDetails: BillingDetails
): Address => {
return {
fullName: stripeBillingDetails.name,
addressLine1: stripeShippingAddress.address.line1,
addressLine2: stripeShippingAddress.address.line2
? stripeShippingAddress.address.line2
: undefined,
city: stripeShippingAddress.address.city,
stateProvinceRegion: stripeShippingAddress.address.state,
country: stripeShippingAddress.address.country,
postalCode: stripeShippingAddress.address.postal_code,
phonePrimary: {
phoneNumber: stripeBillingDetails.phone,
},
};
};