diff --git a/changelog/chore-copy-of-express-checkout-ece-for-tokenized-feature-flag b/changelog/chore-copy-of-express-checkout-ece-for-tokenized-feature-flag
new file mode 100644
index 00000000000..4bfc3e883ad
--- /dev/null
+++ b/changelog/chore-copy-of-express-checkout-ece-for-tokenized-feature-flag
@@ -0,0 +1,5 @@
+Significance: patch
+Type: add
+Comment: chore: create copy of ECE for tokenized cart feature flag
+
+
diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js
index 34a16e23a2b..8376d0b3e8a 100644
--- a/client/checkout/blocks/index.js
+++ b/client/checkout/blocks/index.js
@@ -22,7 +22,10 @@ import {
expressCheckoutElementApplePay,
expressCheckoutElementGooglePay,
} from '../../express-checkout/blocks';
-import tokenizedCartPaymentRequestPaymentMethod from '../../tokenized-payment-request/blocks';
+import {
+ tokenizedExpressCheckoutElementApplePay,
+ tokenizedExpressCheckoutElementGooglePay,
+} from 'wcpay/tokenized-express-checkout/blocks';
import {
PAYMENT_METHOD_NAME_CARD,
@@ -162,7 +165,10 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) {
if ( getUPEConfig( 'isPaymentRequestEnabled' ) ) {
if ( getUPEConfig( 'isTokenizedCartEceEnabled' ) ) {
registerExpressPaymentMethod(
- tokenizedCartPaymentRequestPaymentMethod( api )
+ tokenizedExpressCheckoutElementApplePay( api )
+ );
+ registerExpressPaymentMethod(
+ tokenizedExpressCheckoutElementGooglePay( api )
);
} else {
registerExpressPaymentMethod( expressCheckoutElementApplePay( api ) );
diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-component.js b/client/tokenized-express-checkout/blocks/components/express-checkout-component.js
new file mode 100644
index 00000000000..2ad91ee5881
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/components/express-checkout-component.js
@@ -0,0 +1,154 @@
+/**
+ * External dependencies
+ */
+import { ExpressCheckoutElement } from '@stripe/react-stripe-js';
+/**
+ * Internal dependencies
+ */
+import {
+ shippingAddressChangeHandler,
+ shippingRateChangeHandler,
+} from '../../event-handlers';
+import { useExpressCheckout } from '../hooks/use-express-checkout';
+import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/constants';
+
+const getPaymentMethodsOverride = ( enabledPaymentMethod ) => {
+ const allDisabled = {
+ amazonPay: 'never',
+ applePay: 'never',
+ googlePay: 'never',
+ link: 'never',
+ paypal: 'never',
+ };
+
+ const enabledParam = [ 'applePay', 'googlePay' ].includes(
+ enabledPaymentMethod
+ )
+ ? 'always'
+ : 'auto';
+
+ return {
+ paymentMethods: {
+ ...allDisabled,
+ [ enabledPaymentMethod ]: enabledParam,
+ },
+ };
+};
+
+// Visual adjustments to horizontally align the buttons.
+const adjustButtonHeights = ( buttonOptions, expressPaymentMethod ) => {
+ // Apple Pay has a nearly imperceptible height difference. We increase it by 1px here.
+ if ( buttonOptions.buttonTheme.applePay === 'black' ) {
+ if ( expressPaymentMethod === 'applePay' ) {
+ buttonOptions.buttonHeight = buttonOptions.buttonHeight + 0.4;
+ }
+ }
+
+ // GooglePay with the white theme has a 2px height difference due to its border.
+ if (
+ expressPaymentMethod === 'googlePay' &&
+ buttonOptions.buttonTheme.googlePay === 'white'
+ ) {
+ buttonOptions.buttonHeight = buttonOptions.buttonHeight - 2;
+ }
+
+ // Clamp the button height to the allowed range 40px to 55px.
+ buttonOptions.buttonHeight = Math.max(
+ 40,
+ Math.min( buttonOptions.buttonHeight, 55 )
+ );
+ return buttonOptions;
+};
+
+/**
+ * ExpressCheckout express payment method component.
+ *
+ * @param {Object} props PaymentMethodProps.
+ *
+ * @return {ReactNode} Stripe Elements component.
+ */
+const ExpressCheckoutComponent = ( {
+ api,
+ billing,
+ shippingData,
+ setExpressPaymentError,
+ onClick,
+ onClose,
+ expressPaymentMethod = '',
+ buttonAttributes,
+ isPreview = false,
+} ) => {
+ const {
+ buttonOptions,
+ onButtonClick,
+ onConfirm,
+ onReady,
+ onCancel,
+ elements,
+ } = useExpressCheckout( {
+ api,
+ billing,
+ shippingData,
+ onClick,
+ onClose,
+ setExpressPaymentError,
+ } );
+ const onClickHandler = ! isPreview ? onButtonClick : () => {};
+ const onShippingAddressChange = ( event ) =>
+ shippingAddressChangeHandler( api, event, elements );
+
+ const onShippingRateChange = ( event ) =>
+ shippingRateChangeHandler( api, event, elements );
+
+ const onElementsReady = ( event ) => {
+ const paymentMethodContainer = document.getElementById(
+ `express-payment-method-${ PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT }_${ expressPaymentMethod }`
+ );
+
+ const availablePaymentMethods = event.availablePaymentMethods || {};
+
+ if (
+ paymentMethodContainer &&
+ ! availablePaymentMethods[ expressPaymentMethod ]
+ ) {
+ paymentMethodContainer.remove();
+ }
+
+ // Any actions that WooPayments needs to perform.
+ onReady( event );
+ };
+
+ // The Cart & Checkout blocks provide unified styles across all buttons,
+ // which should override the extension specific settings.
+ const withBlockOverride = () => {
+ const override = {};
+ if ( typeof buttonAttributes !== 'undefined' ) {
+ override.buttonHeight = Number( buttonAttributes.height );
+ }
+ return {
+ ...buttonOptions,
+ ...override,
+ };
+ };
+
+ return (
+
+ );
+};
+
+export default ExpressCheckoutComponent;
diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-container.js b/client/tokenized-express-checkout/blocks/components/express-checkout-container.js
new file mode 100644
index 00000000000..163e177c141
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/components/express-checkout-container.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { useMemo } from 'react';
+import { Elements } from '@stripe/react-stripe-js';
+
+/**
+ * Internal dependencies
+ */
+import ExpressCheckoutComponent from './express-checkout-component';
+import {
+ getExpressCheckoutButtonAppearance,
+ getExpressCheckoutData,
+} from 'wcpay/express-checkout/utils';
+import '../express-checkout-element.scss';
+
+const ExpressCheckoutContainer = ( props ) => {
+ const { api, billing, buttonAttributes, isPreview } = props;
+
+ const stripePromise = useMemo( () => {
+ return api.loadStripeForExpressCheckout();
+ }, [ api ] );
+
+ const options = {
+ mode: 'payment',
+ paymentMethodCreation: 'manual',
+ amount: ! isPreview ? billing.cartTotal.value : 10,
+ currency: ! isPreview ? billing.currency.code.toLowerCase() : 'usd',
+ appearance: getExpressCheckoutButtonAppearance( buttonAttributes ),
+ locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en',
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default ExpressCheckoutContainer;
diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js b/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js
new file mode 100644
index 00000000000..58e5ea2aaae
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js
@@ -0,0 +1,108 @@
+/**
+ * External dependencies
+ */
+import { useState } from 'react';
+import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import InlineNotice from 'components/inline-notice';
+import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout';
+
+export const ExpressCheckoutPreviewComponent = ( {
+ stripe,
+ buttonType,
+ theme,
+ height,
+ radius,
+} ) => {
+ const [ canRenderButtons, setCanRenderButtons ] = useState( true );
+
+ const options = {
+ mode: 'payment',
+ amount: 1000,
+ currency: 'usd',
+ appearance: {
+ variables: {
+ borderRadius: `${ radius ?? getDefaultBorderRadius() }px`,
+ spacingUnit: '6px',
+ },
+ },
+ };
+
+ const mapThemeConfigToButtonTheme = ( paymentMethod, buttonTheme ) => {
+ switch ( buttonTheme ) {
+ case 'dark':
+ return 'black';
+ case 'light':
+ return 'white';
+ case 'light-outline':
+ if ( paymentMethod === 'googlePay' ) {
+ return 'white';
+ }
+
+ return 'white-outline';
+ default:
+ return 'black';
+ }
+ };
+
+ const type = buttonType === 'default' ? 'plain' : buttonType;
+
+ const buttonOptions = {
+ buttonHeight: Math.min( Math.max( height, 40 ), 55 ),
+ buttonTheme: {
+ googlePay: mapThemeConfigToButtonTheme( 'googlePay', theme ),
+ applePay: mapThemeConfigToButtonTheme( 'applePay', theme ),
+ },
+ buttonType: {
+ googlePay: type,
+ applePay: type,
+ },
+ paymentMethods: {
+ link: 'never',
+ googlePay: 'always',
+ applePay: 'always',
+ },
+ layout: { overflow: 'never' },
+ };
+
+ const onReady = ( { availablePaymentMethods } ) => {
+ if ( availablePaymentMethods ) {
+ setCanRenderButtons( true );
+ } else {
+ setCanRenderButtons( false );
+ }
+ };
+
+ if ( canRenderButtons ) {
+ return (
+
+
+ {} }
+ onReady={ onReady }
+ />
+
+
+ );
+ }
+
+ return (
+
+ { __(
+ 'Failed to preview the Apple Pay or Google Pay button. ' +
+ 'Ensure your store uses HTTPS on a publicly available domain ' +
+ "and you're viewing this page in a Safari or Chrome browser. " +
+ 'Your device must be configured to use Apple Pay or Google Pay.',
+ 'woocommerce-payments'
+ ) }
+
+ );
+};
diff --git a/client/tokenized-express-checkout/blocks/express-checkout-element.scss b/client/tokenized-express-checkout/blocks/express-checkout-element.scss
new file mode 100644
index 00000000000..4b4e3d5e29e
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/express-checkout-element.scss
@@ -0,0 +1,23 @@
+// Cart Block
+.wc-block-components-express-payment--cart {
+ .wc-block-components-express-payment__event-buttons > li {
+ padding-bottom: 12px !important;
+
+ &:last-child {
+ padding-bottom: 0 !important;
+ }
+ }
+}
+
+// OR separator
+.wc-block-components-express-payment-continue-rule--cart {
+ margin: 24px 0 !important;
+ height: 20px;
+}
+
+.wc-block-components-express-payment
+ .wc-block-components-express-payment__event-buttons
+ > li {
+ margin-left: 1px !important;
+ width: 99% !important;
+}
diff --git a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js
new file mode 100644
index 00000000000..88f72e76829
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js
@@ -0,0 +1,108 @@
+/**
+ * External dependencies
+ */
+import { useCallback } from '@wordpress/element';
+import { useStripe, useElements } from '@stripe/react-stripe-js';
+
+/**
+ * Internal dependencies
+ */
+import {
+ getExpressCheckoutButtonStyleSettings,
+ getExpressCheckoutData,
+ normalizeLineItems,
+} from 'wcpay/express-checkout/utils';
+import {
+ onAbortPaymentHandler,
+ onCancelHandler,
+ onClickHandler,
+ onCompletePaymentHandler,
+ onConfirmHandler,
+ onReadyHandler,
+} from 'wcpay/express-checkout/event-handlers';
+
+export const useExpressCheckout = ( {
+ api,
+ billing,
+ shippingData,
+ onClick,
+ onClose,
+ setExpressPaymentError,
+} ) => {
+ const stripe = useStripe();
+ const elements = useElements();
+
+ const buttonOptions = getExpressCheckoutButtonStyleSettings();
+
+ const onCancel = () => {
+ onCancelHandler();
+ onClose();
+ };
+
+ const completePayment = ( redirectUrl ) => {
+ onCompletePaymentHandler( redirectUrl );
+ window.location = redirectUrl;
+ };
+
+ const abortPayment = ( onConfirmEvent, message ) => {
+ onConfirmEvent.paymentFailed( { reason: 'fail' } );
+ setExpressPaymentError( message );
+ onAbortPaymentHandler( onConfirmEvent, message );
+ };
+
+ const onButtonClick = useCallback(
+ ( event ) => {
+ const options = {
+ lineItems: normalizeLineItems( billing?.cartTotalItems ),
+ emailRequired: true,
+ shippingAddressRequired: shippingData?.needsShipping,
+ phoneNumberRequired:
+ getExpressCheckoutData( 'checkout' )?.needs_payer_phone ??
+ false,
+ shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map(
+ ( r ) => {
+ return {
+ id: r.rate_id,
+ amount: parseInt( r.price, 10 ),
+ displayName: r.name,
+ };
+ }
+ ),
+ allowedShippingCountries: getExpressCheckoutData( 'checkout' )
+ .allowed_shipping_countries,
+ };
+
+ // Click event from WC Blocks.
+ onClick();
+ // Global click event handler from WooPayments to ECE.
+ onClickHandler( event );
+ event.resolve( options );
+ },
+ [
+ onClick,
+ billing.cartTotalItems,
+ shippingData.needsShipping,
+ shippingData.shippingRates,
+ ]
+ );
+
+ const onConfirm = async ( event ) => {
+ onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+ };
+
+ return {
+ buttonOptions,
+ onButtonClick,
+ onConfirm,
+ onReady: onReadyHandler,
+ onCancel,
+ elements,
+ };
+};
diff --git a/client/tokenized-express-checkout/blocks/index.js b/client/tokenized-express-checkout/blocks/index.js
new file mode 100644
index 00000000000..d2cefd85eb7
--- /dev/null
+++ b/client/tokenized-express-checkout/blocks/index.js
@@ -0,0 +1,85 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/constants';
+import { getConfig } from 'wcpay/utils/checkout';
+import ExpressCheckoutContainer from './components/express-checkout-container';
+import { checkPaymentMethodIsAvailable } from '../utils/checkPaymentMethodIsAvailable';
+
+export const tokenizedExpressCheckoutElementApplePay = ( api ) => ( {
+ paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT,
+ name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT + '_applePay',
+ title: 'WooPayments - Apple Pay',
+ description: __(
+ "An easy, secure way to pay that's accepted on millions of stores.",
+ 'woocommerce-payments'
+ ),
+ gatewayId: 'woocommerce_payments',
+ content: (
+
+ ),
+ edit: (
+
+ ),
+ supports: {
+ features: getConfig( 'features' ),
+ style: [ 'height', 'borderRadius' ],
+ },
+ canMakePayment: ( { cart } ) => {
+ if ( typeof wcpayExpressCheckoutParams === 'undefined' ) {
+ return false;
+ }
+
+ return new Promise( ( resolve ) => {
+ checkPaymentMethodIsAvailable( 'applePay', cart, resolve );
+ } );
+ },
+} );
+
+export const tokenizedExpressCheckoutElementGooglePay = ( api ) => {
+ return {
+ paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT,
+ name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT + '_googlePay',
+ title: 'WooPayments - Google Pay',
+ description: __(
+ 'Simplify checkout with fewer steps to pay.',
+ 'woocommerce-payments'
+ ),
+ gatewayId: 'woocommerce_payments',
+ content: (
+
+ ),
+ edit: (
+
+ ),
+ supports: {
+ features: getConfig( 'features' ),
+ style: [ 'height', 'borderRadius' ],
+ },
+ canMakePayment: ( { cart } ) => {
+ if ( typeof wcpayExpressCheckoutParams === 'undefined' ) {
+ return false;
+ }
+
+ return new Promise( ( resolve ) => {
+ checkPaymentMethodIsAvailable( 'googlePay', cart, resolve );
+ } );
+ },
+ };
+};
diff --git a/client/tokenized-express-checkout/event-handlers.js b/client/tokenized-express-checkout/event-handlers.js
new file mode 100644
index 00000000000..2d1345ff752
--- /dev/null
+++ b/client/tokenized-express-checkout/event-handlers.js
@@ -0,0 +1,175 @@
+/* global jQuery */
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+/**
+ * Internal dependencies
+ */
+import {
+ getErrorMessageFromNotice,
+ normalizeOrderData,
+ normalizePayForOrderData,
+ normalizeShippingAddress,
+ normalizeLineItems,
+ getExpressCheckoutData,
+} from './utils';
+import {
+ trackExpressCheckoutButtonClick,
+ trackExpressCheckoutButtonLoad,
+} from './tracking';
+
+export const shippingAddressChangeHandler = async ( api, event, elements ) => {
+ try {
+ const response = await api.expressCheckoutECECalculateShippingOptions(
+ normalizeShippingAddress( event.address )
+ );
+
+ if ( response.result === 'success' ) {
+ elements.update( {
+ amount: response.total.amount,
+ } );
+ event.resolve( {
+ shippingRates: response.shipping_options,
+ lineItems: normalizeLineItems( response.displayItems ),
+ } );
+ } else {
+ event.reject();
+ }
+ } catch ( e ) {
+ event.reject();
+ }
+};
+
+export const shippingRateChangeHandler = async ( api, event, elements ) => {
+ try {
+ const response = await api.expressCheckoutECEUpdateShippingDetails(
+ event.shippingRate
+ );
+
+ if ( response.result === 'success' ) {
+ elements.update( { amount: response.total.amount } );
+ event.resolve( {
+ lineItems: normalizeLineItems( response.displayItems ),
+ } );
+ } else {
+ event.reject();
+ }
+ } catch ( e ) {
+ event.reject();
+ }
+};
+
+export const onConfirmHandler = async (
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event,
+ order = 0 // Order ID for the pay for order flow.
+) => {
+ const { error: submitError } = await elements.submit();
+ if ( submitError ) {
+ return abortPayment( event, submitError.message );
+ }
+
+ const { paymentMethod, error } = await stripe.createPaymentMethod( {
+ elements,
+ } );
+
+ if ( error ) {
+ return abortPayment( event, error.message );
+ }
+
+ try {
+ // Kick off checkout processing step.
+ let orderResponse;
+ if ( ! order ) {
+ orderResponse = await api.expressCheckoutECECreateOrder(
+ normalizeOrderData( event, paymentMethod.id )
+ );
+ } else {
+ orderResponse = await api.expressCheckoutECEPayForOrder(
+ order,
+ normalizePayForOrderData( event, paymentMethod.id )
+ );
+ }
+
+ if ( orderResponse.result !== 'success' ) {
+ return abortPayment(
+ event,
+ getErrorMessageFromNotice( orderResponse.messages )
+ );
+ }
+
+ const confirmationRequest = api.confirmIntent( orderResponse.redirect );
+
+ // `true` means there is no intent to confirm.
+ if ( confirmationRequest === true ) {
+ completePayment( orderResponse.redirect );
+ } else {
+ const redirectUrl = await confirmationRequest;
+
+ completePayment( redirectUrl );
+ }
+ } catch ( e ) {
+ return abortPayment(
+ event,
+ e.message ??
+ __(
+ 'There was a problem processing the order.',
+ 'woocommerce-payments'
+ )
+ );
+ }
+};
+
+export const onReadyHandler = async function ( { availablePaymentMethods } ) {
+ if ( availablePaymentMethods ) {
+ const enabledMethods = Object.entries( availablePaymentMethods )
+ // eslint-disable-next-line no-unused-vars
+ .filter( ( [ _, isEnabled ] ) => isEnabled )
+ // eslint-disable-next-line no-unused-vars
+ .map( ( [ methodName, _ ] ) => methodName );
+
+ trackExpressCheckoutButtonLoad( {
+ paymentMethods: enabledMethods,
+ source: getExpressCheckoutData( 'button_context' ),
+ } );
+ }
+};
+
+const blockUI = () => {
+ jQuery.blockUI( {
+ message: null,
+ overlayCSS: {
+ background: '#fff',
+ opacity: 0.6,
+ },
+ } );
+};
+
+const unblockUI = () => {
+ jQuery.unblockUI();
+};
+
+export const onClickHandler = async function ( { expressPaymentType } ) {
+ blockUI();
+ trackExpressCheckoutButtonClick(
+ expressPaymentType,
+ getExpressCheckoutData( 'button_context' )
+ );
+};
+
+export const onAbortPaymentHandler = () => {
+ unblockUI();
+};
+
+export const onCompletePaymentHandler = () => {
+ blockUI();
+};
+
+export const onCancelHandler = () => {
+ unblockUI();
+};
diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js
new file mode 100644
index 00000000000..8b35c61db89
--- /dev/null
+++ b/client/tokenized-express-checkout/index.js
@@ -0,0 +1,667 @@
+/* global jQuery, wcpayExpressCheckoutParams, wcpayECEPayForOrderParams */
+import { __ } from '@wordpress/i18n';
+import { debounce } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import WCPayAPI from '../checkout/api';
+import '../checkout/express-checkout-buttons.scss';
+import {
+ getExpressCheckoutButtonAppearance,
+ getExpressCheckoutButtonStyleSettings,
+ getExpressCheckoutData,
+ normalizeLineItems,
+} from './utils/index';
+import {
+ onAbortPaymentHandler,
+ onCancelHandler,
+ onClickHandler,
+ onCompletePaymentHandler,
+ onConfirmHandler,
+ onReadyHandler,
+ shippingAddressChangeHandler,
+ shippingRateChangeHandler,
+} from './event-handlers';
+import { displayLoginConfirmation } from './utils';
+
+jQuery( ( $ ) => {
+ // Don't load if blocks checkout is being loaded.
+ if (
+ wcpayExpressCheckoutParams.has_block &&
+ ! wcpayExpressCheckoutParams.is_pay_for_order
+ ) {
+ return;
+ }
+
+ const publishableKey = wcpayExpressCheckoutParams.stripe.publishableKey;
+ const quantityInputSelector = '.quantity .qty[type=number]';
+
+ if ( ! publishableKey ) {
+ // If no configuration is present, probably this is not the checkout page.
+ return;
+ }
+
+ const api = new WCPayAPI(
+ {
+ publishableKey,
+ accountId: wcpayExpressCheckoutParams.stripe.accountId,
+ locale: wcpayExpressCheckoutParams.stripe.locale,
+ },
+ // A promise-based interface to jQuery.post.
+ ( url, args ) => {
+ return new Promise( ( resolve, reject ) => {
+ jQuery.post( url, args ).then( resolve ).fail( reject );
+ } );
+ }
+ );
+
+ let wcPayECEError = '';
+ const defaultErrorMessage = __(
+ 'There was an error getting the product information.',
+ 'woocommerce-payments'
+ );
+
+ /**
+ * Object to handle Stripe payment forms.
+ */
+ const wcpayECE = {
+ getAttributes: function () {
+ const select = $( '.variations_form' ).find( '.variations select' );
+ const data = {};
+ let count = 0;
+ let chosen = 0;
+
+ select.each( function () {
+ const attributeName =
+ $( this ).data( 'attribute_name' ) ||
+ $( this ).attr( 'name' );
+ const value = $( this ).val() || '';
+
+ if ( value.length > 0 ) {
+ chosen++;
+ }
+
+ count++;
+ data[ attributeName ] = value;
+ } );
+
+ return {
+ count: count,
+ chosenCount: chosen,
+ data: data,
+ };
+ },
+
+ /**
+ * Abort the payment and display error messages.
+ *
+ * @param {PaymentResponse} payment Payment response instance.
+ * @param {string} message Error message to display.
+ */
+ abortPayment: ( payment, message ) => {
+ payment.paymentFailed( { reason: 'fail' } );
+ onAbortPaymentHandler( payment, message );
+
+ $( '.woocommerce-error' ).remove();
+
+ const $container = $( '.woocommerce-notices-wrapper' ).first();
+
+ if ( $container.length ) {
+ $container.append(
+ $( '' ).text( message )
+ );
+
+ $( 'html, body' ).animate(
+ {
+ scrollTop: $container
+ .find( '.woocommerce-error' )
+ .offset().top,
+ },
+ 600
+ );
+ }
+ },
+
+ /**
+ * Complete payment.
+ *
+ * @param {string} url Order thank you page URL.
+ */
+ completePayment: ( url ) => {
+ onCompletePaymentHandler( url );
+ window.location = url;
+ },
+
+ /**
+ * Adds the item to the cart and return cart details.
+ *
+ * @return {Promise} Promise for the request to the server.
+ */
+ addToCart: () => {
+ let productId = $( '.single_add_to_cart_button' ).val();
+
+ // Check if product is a variable product.
+ if ( $( '.single_variation_wrap' ).length ) {
+ productId = $( '.single_variation_wrap' )
+ .find( 'input[name="product_id"]' )
+ .val();
+ }
+
+ if ( $( '.wc-bookings-booking-form' ).length ) {
+ productId = $( '.wc-booking-product-id' ).val();
+ }
+
+ const data = {
+ product_id: productId,
+ qty: $( quantityInputSelector ).val(),
+ attributes: $( '.variations_form' ).length
+ ? wcpayECE.getAttributes().data
+ : [],
+ };
+
+ // Add extension data to the POST body
+ const formData = $( 'form.cart' ).serializeArray();
+ $.each( formData, ( i, field ) => {
+ if ( /^(addon-|wc_)/.test( field.name ) ) {
+ if ( /\[\]$/.test( field.name ) ) {
+ const fieldName = field.name.substring(
+ 0,
+ field.name.length - 2
+ );
+ if ( data[ fieldName ] ) {
+ data[ fieldName ].push( field.value );
+ } else {
+ data[ fieldName ] = [ field.value ];
+ }
+ } else {
+ data[ field.name ] = field.value;
+ }
+ }
+ } );
+
+ return api.expressCheckoutECEAddToCart( data );
+ },
+
+ /**
+ * Starts the Express Checkout Element
+ *
+ * @param {Object} options ECE options.
+ */
+ startExpressCheckoutElement: ( options ) => {
+ const getShippingRates = () => {
+ if ( ! options.requestShipping ) {
+ return [];
+ }
+
+ if ( getExpressCheckoutData( 'is_product_page' ) ) {
+ // Despite the name of the property, this seems to be just a single option that's not in an array.
+ const {
+ shippingOptions: shippingOption,
+ } = getExpressCheckoutData( 'product' );
+
+ return [
+ {
+ id: shippingOption.id,
+ amount: shippingOption.amount,
+ displayName: shippingOption.label,
+ },
+ ];
+ }
+
+ return options.displayItems
+ .filter( ( i ) => i.key === 'total_shipping' )
+ .map( ( i ) => ( {
+ id: `rate-${ i.label }`,
+ amount: i.amount,
+ displayName: i.label,
+ } ) );
+ };
+
+ const shippingRates = getShippingRates();
+
+ // This is a bit of a hack, but we need some way to get the shipping information before rendering the button, and
+ // since we don't have any address information at this point it seems best to rely on what came with the cart response.
+ // Relying on what's provided in the cart response seems safest since it should always include a valid shipping
+ // rate if one is required and available.
+ // If no shipping rate is found we can't render the button so we just exit.
+ if ( options.requestShipping && ! shippingRates.length ) {
+ return;
+ }
+
+ const elements = api.getStripe().elements( {
+ mode: options?.mode ?? 'payment',
+ amount: options?.total,
+ currency: options?.currency,
+ paymentMethodCreation: 'manual',
+ appearance: getExpressCheckoutButtonAppearance(),
+ locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en',
+ } );
+
+ const eceButton = wcpayECE.createButton(
+ elements,
+ getExpressCheckoutButtonStyleSettings()
+ );
+
+ wcpayECE.renderButton( eceButton );
+
+ eceButton.on( 'loaderror', () => {
+ wcPayECEError = __(
+ 'The cart is incompatible with express checkout.',
+ 'woocommerce-payments'
+ );
+ if ( ! document.getElementById( 'wcpay-woopay-button' ) ) {
+ wcpayECE?.getButtonSeparator()?.hide();
+ }
+ } );
+
+ eceButton.on( 'click', function ( event ) {
+ // If login is required for checkout, display redirect confirmation dialog.
+ if ( getExpressCheckoutData( 'login_confirmation' ) ) {
+ displayLoginConfirmation( event.expressPaymentType );
+ return;
+ }
+
+ if ( getExpressCheckoutData( 'is_product_page' ) ) {
+ const addToCartButton = $( '.single_add_to_cart_button' );
+
+ // First check if product can be added to cart.
+ if ( addToCartButton.is( '.disabled' ) ) {
+ if (
+ addToCartButton.is( '.wc-variation-is-unavailable' )
+ ) {
+ window.alert(
+ window?.wc_add_to_cart_variation_params
+ ?.i18n_unavailable_text ||
+ __(
+ 'Sorry, this product is unavailable. Please choose a different combination.',
+ 'woocommerce-payments'
+ )
+ );
+ } else {
+ window.alert(
+ __(
+ 'Please select your product options before proceeding.',
+ 'woocommerce-payments'
+ )
+ );
+ }
+ return;
+ }
+
+ if ( wcPayECEError ) {
+ window.alert( wcPayECEError );
+ return;
+ }
+
+ // Add products to the cart if everything is right.
+ wcpayECE.addToCart();
+ }
+
+ const clickOptions = {
+ lineItems: normalizeLineItems( options.displayItems ),
+ emailRequired: true,
+ shippingAddressRequired: options.requestShipping,
+ phoneNumberRequired: options.requestPhone,
+ shippingRates,
+ allowedShippingCountries: getExpressCheckoutData(
+ 'checkout'
+ ).allowed_shipping_countries,
+ };
+
+ onClickHandler( event );
+ event.resolve( clickOptions );
+ } );
+
+ eceButton.on( 'shippingaddresschange', async ( event ) =>
+ shippingAddressChangeHandler( api, event, elements )
+ );
+
+ eceButton.on( 'shippingratechange', async ( event ) =>
+ shippingRateChangeHandler( api, event, elements )
+ );
+
+ eceButton.on( 'confirm', async ( event ) => {
+ const order = options.order ?? 0;
+
+ return onConfirmHandler(
+ api,
+ api.getStripe(),
+ elements,
+ wcpayECE.completePayment,
+ wcpayECE.abortPayment,
+ event,
+ order
+ );
+ } );
+
+ eceButton.on( 'cancel', async () => {
+ wcpayECE.paymentAborted = true;
+ onCancelHandler();
+ } );
+
+ eceButton.on( 'ready', ( onReadyParams ) => {
+ onReadyHandler( onReadyParams );
+
+ if (
+ onReadyParams?.availablePaymentMethods &&
+ Object.values(
+ onReadyParams.availablePaymentMethods
+ ).filter( Boolean ).length
+ ) {
+ wcpayECE.show();
+ wcpayECE.getButtonSeparator().show();
+ }
+ } );
+
+ if ( getExpressCheckoutData( 'is_product_page' ) ) {
+ wcpayECE.attachProductPageEventListeners( elements );
+ }
+ },
+
+ getSelectedProductData: () => {
+ let productId = $( '.single_add_to_cart_button' ).val();
+
+ // Check if product is a variable product.
+ if ( $( '.single_variation_wrap' ).length ) {
+ productId = $( '.single_variation_wrap' )
+ .find( 'input[name="product_id"]' )
+ .val();
+ }
+
+ if ( $( '.wc-bookings-booking-form' ).length ) {
+ productId = $( '.wc-booking-product-id' ).val();
+ }
+
+ const addons =
+ $( '#product-addons-total' ).data( 'price_data' ) || [];
+ const addonValue = addons.reduce(
+ ( sum, addon ) => sum + addon.cost,
+ 0
+ );
+
+ // WC Deposits Support.
+ const depositObject = {};
+ if ( $( 'input[name=wc_deposit_option]' ).length ) {
+ depositObject.wc_deposit_option = $(
+ 'input[name=wc_deposit_option]:checked'
+ ).val();
+ }
+ if ( $( 'input[name=wc_deposit_payment_plan]' ).length ) {
+ depositObject.wc_deposit_payment_plan = $(
+ 'input[name=wc_deposit_payment_plan]:checked'
+ ).val();
+ }
+
+ const data = {
+ product_id: productId,
+ qty: $( quantityInputSelector ).val(),
+ attributes: $( '.variations_form' ).length
+ ? wcpayECE.getAttributes().data
+ : [],
+ addon_value: addonValue,
+ ...depositObject,
+ };
+
+ return api.expressCheckoutECEGetSelectedProductData( data );
+ },
+
+ /**
+ * Creates Stripe Express Checkout Element.
+ *
+ * @param {Object} elements Stripe elements instance.
+ * @param {Object} options Options for creating the Express Checkout Element.
+ *
+ * @return {Object} Stripe Express Checkout Element.
+ */
+ createButton: ( elements, options ) => {
+ return elements.create( 'expressCheckout', options );
+ },
+
+ attachProductPageEventListeners: ( elements ) => {
+ // WooCommerce Deposits support.
+ // Trigger the "woocommerce_variation_has_changed" event when the deposit option is changed.
+ // Needs to be defined before the `woocommerce_variation_has_changed` event handler is set.
+ $(
+ 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]'
+ )
+ .off( 'change' )
+ .on( 'change', () => {
+ $( 'form' )
+ .has(
+ 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]'
+ )
+ .trigger( 'woocommerce_variation_has_changed' );
+ } );
+
+ $( document.body )
+ .off( 'woocommerce_variation_has_changed' )
+ .on( 'woocommerce_variation_has_changed', () => {
+ wcpayECE.blockExpressCheckoutButton();
+
+ $.when( wcpayECE.getSelectedProductData() )
+ .then( ( response ) => {
+ const isDeposits = wcpayECE.productHasDepositOption();
+ /**
+ * If the customer aborted the express checkout,
+ * we need to re init the express checkout button to ensure the shipping
+ * options are refetched. If the customer didn't abort the express checkout,
+ * and the product's shipping status is consistent,
+ * we can simply update the express checkout button with the new total and display items.
+ */
+ const needsShipping =
+ ! wcpayECE.paymentAborted &&
+ getExpressCheckoutData( 'product' )
+ .needs_shipping === response.needs_shipping;
+
+ if ( ! isDeposits && needsShipping ) {
+ elements.update( {
+ amount: response.total.amount,
+ } );
+ } else {
+ wcpayECE.reInitExpressCheckoutElement(
+ response
+ );
+ }
+ } )
+ .catch( () => {
+ wcpayECE.hide();
+ } )
+ .always( () => {
+ wcpayECE.unblockExpressCheckoutButton();
+ } );
+ } );
+
+ $( '.quantity' )
+ .off( 'input', '.qty' )
+ .on(
+ 'input',
+ '.qty',
+ debounce( () => {
+ wcpayECE.blockExpressCheckoutButton();
+ wcPayECEError = '';
+
+ $.when( wcpayECE.getSelectedProductData() )
+ .then(
+ ( response ) => {
+ // In case the server returns an unexpected response
+ if ( typeof response !== 'object' ) {
+ wcPayECEError = defaultErrorMessage;
+ }
+
+ if (
+ ! wcpayECE.paymentAborted &&
+ getExpressCheckoutData( 'product' )
+ .needs_shipping ===
+ response.needs_shipping
+ ) {
+ elements.update( {
+ amount: response.total.amount,
+ } );
+ } else {
+ wcpayECE.reInitExpressCheckoutElement(
+ response
+ );
+ }
+ },
+ ( response ) => {
+ wcPayECEError =
+ response.responseJSON?.error ??
+ defaultErrorMessage;
+ }
+ )
+ .always( function () {
+ wcpayECE.unblockExpressCheckoutButton();
+ } );
+ }, 250 )
+ );
+ },
+
+ reInitExpressCheckoutElement: ( response ) => {
+ wcpayExpressCheckoutParams.product.needs_shipping =
+ response.needs_shipping;
+ wcpayExpressCheckoutParams.product.total = response.total;
+ wcpayExpressCheckoutParams.product.displayItems =
+ response.displayItems;
+ wcpayECE.init();
+ },
+
+ blockExpressCheckoutButton: () => {
+ // check if element isn't already blocked before calling block() to avoid blinking overlay issues
+ // blockUI.isBlocked is either undefined or 0 when element is not blocked
+ if (
+ $( '#wcpay-express-checkout-element' ).data(
+ 'blockUI.isBlocked'
+ )
+ ) {
+ return;
+ }
+
+ $( '#wcpay-express-checkout-element' ).block( { message: null } );
+ },
+
+ unblockExpressCheckoutButton: () => {
+ wcpayECE.show();
+ $( '#wcpay-express-checkout-element' ).unblock();
+ },
+
+ getElements: () => {
+ return $( '#wcpay-express-checkout-element' );
+ },
+
+ getButtonSeparator: () => {
+ return $( '#wcpay-express-checkout-button-separator' );
+ },
+
+ show: () => {
+ wcpayECE.getElements().show();
+ },
+
+ hide: () => {
+ wcpayECE.getElements().hide();
+ wcpayECE.getButtonSeparator().hide();
+ },
+
+ renderButton: ( eceButton ) => {
+ if ( $( '#wcpay-express-checkout-element' ).length ) {
+ eceButton.mount( '#wcpay-express-checkout-element' );
+ }
+ },
+
+ productHasDepositOption() {
+ return !! $( 'form' ).has(
+ 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]'
+ ).length;
+ },
+
+ /**
+ * Initialize event handlers and UI state
+ */
+ init: () => {
+ if ( wcpayExpressCheckoutParams.is_pay_for_order ) {
+ if ( ! window.wcpayECEPayForOrderParams ) {
+ return;
+ }
+
+ const {
+ total: { amount: total },
+ displayItems,
+ order,
+ } = wcpayECEPayForOrderParams;
+
+ if ( total === 0 ) {
+ wcpayECE.hide();
+ return;
+ }
+
+ wcpayECE.startExpressCheckoutElement( {
+ mode: 'payment',
+ total,
+ currency: getExpressCheckoutData( 'checkout' )
+ ?.currency_code,
+ requestShipping: false,
+ requestPhone:
+ getExpressCheckoutData( 'checkout' )
+ ?.needs_payer_phone ?? false,
+ displayItems,
+ order,
+ } );
+ } else if ( wcpayExpressCheckoutParams.is_product_page ) {
+ wcpayECE.startExpressCheckoutElement( {
+ mode: 'payment',
+ total: getExpressCheckoutData( 'product' )?.total.amount,
+ currency: getExpressCheckoutData( 'product' )?.currency,
+ requestShipping:
+ getExpressCheckoutData( 'product' )?.needs_shipping ??
+ false,
+ requestPhone:
+ getExpressCheckoutData( 'checkout' )
+ ?.needs_payer_phone ?? false,
+ displayItems:
+ wcpayExpressCheckoutParams.product.displayItems,
+ } );
+ } else {
+ // If this is the cart or checkout page, we need to request the
+ // cart details.
+ api.expressCheckoutECEGetCartDetails().then( ( cart ) => {
+ if ( cart.total.amount === 0 ) {
+ wcpayECE.hide();
+ } else {
+ wcpayECE.startExpressCheckoutElement( {
+ mode: 'payment',
+ total: cart.total.amount,
+ currency: getExpressCheckoutData( 'checkout' )
+ ?.currency_code,
+ requestShipping: cart.needs_shipping,
+ requestPhone:
+ getExpressCheckoutData( 'checkout' )
+ ?.needs_payer_phone ?? false,
+ displayItems: cart.displayItems,
+ } );
+ }
+ } );
+ }
+
+ // After initializing a new express checkout button, we need to reset the paymentAborted flag.
+ wcpayECE.paymentAborted = false;
+ },
+ };
+
+ // We don't need to initialize ECE on the checkout page now because it will be initialized by updated_checkout event.
+ if (
+ ! wcpayExpressCheckoutParams.is_checkout_page ||
+ wcpayExpressCheckoutParams.is_pay_for_order
+ ) {
+ wcpayECE.init();
+ }
+
+ // We need to refresh ECE data when total is updated.
+ $( document.body ).on( 'updated_cart_totals', () => {
+ wcpayECE.init();
+ } );
+
+ // We need to refresh ECE data when total is updated.
+ $( document.body ).on( 'updated_checkout', () => {
+ wcpayECE.init();
+ } );
+} );
diff --git a/client/tokenized-express-checkout/test/event-handlers.js b/client/tokenized-express-checkout/test/event-handlers.js
new file mode 100644
index 00000000000..8c3d8a1c28b
--- /dev/null
+++ b/client/tokenized-express-checkout/test/event-handlers.js
@@ -0,0 +1,571 @@
+/**
+ * Internal dependencies
+ */
+import {
+ shippingAddressChangeHandler,
+ shippingRateChangeHandler,
+ onConfirmHandler,
+} from '../event-handlers';
+import {
+ normalizeLineItems,
+ normalizeShippingAddress,
+ normalizeOrderData,
+ normalizePayForOrderData,
+} from '../utils';
+
+describe( 'Express checkout event handlers', () => {
+ describe( 'shippingAddressChangeHandler', () => {
+ let api;
+ let event;
+ let elements;
+
+ beforeEach( () => {
+ api = {
+ expressCheckoutECECalculateShippingOptions: jest.fn(),
+ };
+ event = {
+ address: {
+ recipient: 'John Doe',
+ addressLine: [ '123 Main St' ],
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postal_code: '10001',
+ },
+ resolve: jest.fn(),
+ reject: jest.fn(),
+ };
+ elements = {
+ update: jest.fn(),
+ };
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ test( 'should handle successful response', async () => {
+ const response = {
+ result: 'success',
+ total: { amount: 1000 },
+ shipping_options: [
+ { id: 'option_1', label: 'Standard Shipping' },
+ ],
+ displayItems: [ { label: 'Sample Item', amount: 500 } ],
+ };
+
+ api.expressCheckoutECECalculateShippingOptions.mockResolvedValue(
+ response
+ );
+
+ await shippingAddressChangeHandler( api, event, elements );
+
+ const expectedNormalizedAddress = normalizeShippingAddress(
+ event.address
+ );
+ expect(
+ api.expressCheckoutECECalculateShippingOptions
+ ).toHaveBeenCalledWith( expectedNormalizedAddress );
+
+ const expectedNormalizedLineItems = normalizeLineItems(
+ response.displayItems
+ );
+ expect( elements.update ).toHaveBeenCalledWith( { amount: 1000 } );
+ expect( event.resolve ).toHaveBeenCalledWith( {
+ shippingRates: response.shipping_options,
+ lineItems: expectedNormalizedLineItems,
+ } );
+ expect( event.reject ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should handle unsuccessful response', async () => {
+ const response = {
+ result: 'error',
+ };
+
+ api.expressCheckoutECECalculateShippingOptions.mockResolvedValue(
+ response
+ );
+
+ await shippingAddressChangeHandler( api, event, elements );
+
+ const expectedNormalizedAddress = normalizeShippingAddress(
+ event.address
+ );
+ expect(
+ api.expressCheckoutECECalculateShippingOptions
+ ).toHaveBeenCalledWith( expectedNormalizedAddress );
+ expect( elements.update ).not.toHaveBeenCalled();
+ expect( event.resolve ).not.toHaveBeenCalled();
+ expect( event.reject ).toHaveBeenCalled();
+ } );
+
+ test( 'should handle API call failure', async () => {
+ api.expressCheckoutECECalculateShippingOptions.mockRejectedValue(
+ new Error( 'API error' )
+ );
+
+ await shippingAddressChangeHandler( api, event, elements );
+
+ const expectedNormalizedAddress = normalizeShippingAddress(
+ event.address
+ );
+ expect(
+ api.expressCheckoutECECalculateShippingOptions
+ ).toHaveBeenCalledWith( expectedNormalizedAddress );
+ expect( elements.update ).not.toHaveBeenCalled();
+ expect( event.resolve ).not.toHaveBeenCalled();
+ expect( event.reject ).toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'shippingRateChangeHandler', () => {
+ let api;
+ let event;
+ let elements;
+
+ beforeEach( () => {
+ api = {
+ expressCheckoutECEUpdateShippingDetails: jest.fn(),
+ };
+ event = {
+ shippingRate: {
+ id: 'rate_1',
+ label: 'Standard Shipping',
+ amount: 500,
+ },
+ resolve: jest.fn(),
+ reject: jest.fn(),
+ };
+ elements = {
+ update: jest.fn(),
+ };
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ test( 'should handle successful response', async () => {
+ const response = {
+ result: 'success',
+ total: { amount: 1500 },
+ displayItems: [ { label: 'Sample Item', amount: 1000 } ],
+ };
+
+ api.expressCheckoutECEUpdateShippingDetails.mockResolvedValue(
+ response
+ );
+
+ await shippingRateChangeHandler( api, event, elements );
+
+ const expectedNormalizedLineItems = normalizeLineItems(
+ response.displayItems
+ );
+ expect(
+ api.expressCheckoutECEUpdateShippingDetails
+ ).toHaveBeenCalledWith( event.shippingRate );
+ expect( elements.update ).toHaveBeenCalledWith( { amount: 1500 } );
+ expect( event.resolve ).toHaveBeenCalledWith( {
+ lineItems: expectedNormalizedLineItems,
+ } );
+ expect( event.reject ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should handle unsuccessful response', async () => {
+ const response = {
+ result: 'error',
+ };
+
+ api.expressCheckoutECEUpdateShippingDetails.mockResolvedValue(
+ response
+ );
+
+ await shippingRateChangeHandler( api, event, elements );
+
+ expect(
+ api.expressCheckoutECEUpdateShippingDetails
+ ).toHaveBeenCalledWith( event.shippingRate );
+ expect( elements.update ).not.toHaveBeenCalled();
+ expect( event.resolve ).not.toHaveBeenCalled();
+ expect( event.reject ).toHaveBeenCalled();
+ } );
+
+ test( 'should handle API call failure', async () => {
+ api.expressCheckoutECEUpdateShippingDetails.mockRejectedValue(
+ new Error( 'API error' )
+ );
+
+ await shippingRateChangeHandler( api, event, elements );
+
+ expect(
+ api.expressCheckoutECEUpdateShippingDetails
+ ).toHaveBeenCalledWith( event.shippingRate );
+ expect( elements.update ).not.toHaveBeenCalled();
+ expect( event.resolve ).not.toHaveBeenCalled();
+ expect( event.reject ).toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'onConfirmHandler', () => {
+ let api;
+ let stripe;
+ let elements;
+ let completePayment;
+ let abortPayment;
+ let event;
+ let order;
+
+ beforeEach( () => {
+ api = {
+ expressCheckoutECECreateOrder: jest.fn(),
+ expressCheckoutECEPayForOrder: jest.fn(),
+ confirmIntent: jest.fn(),
+ };
+ stripe = {
+ createPaymentMethod: jest.fn(),
+ };
+ elements = {
+ submit: jest.fn(),
+ };
+ completePayment = jest.fn();
+ abortPayment = jest.fn();
+ event = {
+ billingDetails: {
+ name: 'John Doe',
+ email: 'john.doe@example.com',
+ address: {
+ organization: 'Some Company',
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ phone: '(123) 456-7890',
+ },
+ shippingAddress: {
+ name: 'John Doe',
+ organization: 'Some Company',
+ address: {
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ },
+ shippingRate: { id: 'rate_1' },
+ expressPaymentType: 'express',
+ };
+ order = 123;
+ global.window.wcpayFraudPreventionToken = 'token123';
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ test( 'should abort payment if elements.submit fails', async () => {
+ elements.submit.mockResolvedValue( {
+ error: { message: 'Submit error' },
+ } );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ expect( elements.submit ).toHaveBeenCalled();
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Submit error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should abort payment if stripe.createPaymentMethod fails', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ error: { message: 'Payment method error' },
+ } );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ expect( elements.submit ).toHaveBeenCalled();
+ expect( stripe.createPaymentMethod ).toHaveBeenCalledWith( {
+ elements,
+ } );
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Payment method error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should abort payment if expressCheckoutECECreateOrder fails', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECECreateOrder.mockResolvedValue( {
+ result: 'error',
+ messages: 'Order creation error',
+ } );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ const expectedOrderData = normalizeOrderData( event, 'pm_123' );
+ expect( api.expressCheckoutECECreateOrder ).toHaveBeenCalledWith(
+ expectedOrderData
+ );
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Order creation error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should complete payment if confirmationRequest is true', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECECreateOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockReturnValue( true );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( completePayment ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( abortPayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should complete payment if confirmationRequest returns a redirect URL', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECECreateOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockResolvedValue(
+ 'https://example.com/confirmation_redirect'
+ );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( completePayment ).toHaveBeenCalledWith(
+ 'https://example.com/confirmation_redirect'
+ );
+ expect( abortPayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should abort payment if confirmIntent throws an error', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECECreateOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockRejectedValue(
+ new Error( 'Intent confirmation error' )
+ );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Intent confirmation error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should abort payment if expressCheckoutECEPayForOrder fails', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECEPayForOrder.mockResolvedValue( {
+ result: 'error',
+ messages: 'Order creation error',
+ } );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event,
+ order
+ );
+
+ const expectedOrderData = normalizePayForOrderData(
+ event,
+ 'pm_123'
+ );
+ expect( api.expressCheckoutECEPayForOrder ).toHaveBeenCalledWith(
+ 123,
+ expectedOrderData
+ );
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Order creation error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should complete payment (pay for order) if confirmationRequest is true', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECEPayForOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockReturnValue( true );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event,
+ order
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( completePayment ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( abortPayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should complete payment (pay for order) if confirmationRequest returns a redirect URL', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECEPayForOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockResolvedValue(
+ 'https://example.com/confirmation_redirect'
+ );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event,
+ order
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( completePayment ).toHaveBeenCalledWith(
+ 'https://example.com/confirmation_redirect'
+ );
+ expect( abortPayment ).not.toHaveBeenCalled();
+ } );
+
+ test( 'should abort payment (pay for order) if confirmIntent throws an error', async () => {
+ elements.submit.mockResolvedValue( {} );
+ stripe.createPaymentMethod.mockResolvedValue( {
+ paymentMethod: { id: 'pm_123' },
+ } );
+ api.expressCheckoutECEPayForOrder.mockResolvedValue( {
+ result: 'success',
+ redirect: 'https://example.com/redirect',
+ } );
+ api.confirmIntent.mockRejectedValue(
+ new Error( 'Intent confirmation error' )
+ );
+
+ await onConfirmHandler(
+ api,
+ stripe,
+ elements,
+ completePayment,
+ abortPayment,
+ event,
+ order
+ );
+
+ expect( api.confirmIntent ).toHaveBeenCalledWith(
+ 'https://example.com/redirect'
+ );
+ expect( abortPayment ).toHaveBeenCalledWith(
+ event,
+ 'Intent confirmation error'
+ );
+ expect( completePayment ).not.toHaveBeenCalled();
+ } );
+ } );
+} );
diff --git a/client/tokenized-express-checkout/tracking.js b/client/tokenized-express-checkout/tracking.js
new file mode 100644
index 00000000000..862f6fc587e
--- /dev/null
+++ b/client/tokenized-express-checkout/tracking.js
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import { debounce } from 'lodash';
+import { recordUserEvent } from 'tracks';
+
+// Track the button click event.
+export const trackExpressCheckoutButtonClick = ( paymentMethod, source ) => {
+ const expressPaymentTypeEvents = {
+ google_pay: 'gpay_button_click',
+ apple_pay: 'applepay_button_click',
+ };
+
+ const event = expressPaymentTypeEvents[ paymentMethod ];
+ if ( ! event ) return;
+
+ recordUserEvent( event, { source } );
+};
+
+// Track the button load event.
+export const trackExpressCheckoutButtonLoad = debounce(
+ ( { paymentMethods, source } ) => {
+ const expressPaymentTypeEvents = {
+ googlePay: 'gpay_button_load',
+ applePay: 'applepay_button_load',
+ };
+
+ for ( const paymentMethod of paymentMethods ) {
+ const event = expressPaymentTypeEvents[ paymentMethod ];
+ if ( ! event ) continue;
+
+ recordUserEvent( event, { source } );
+ }
+ },
+ 1000
+);
diff --git a/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js b/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js
new file mode 100644
index 00000000000..b592169da22
--- /dev/null
+++ b/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js
@@ -0,0 +1,84 @@
+/**
+ * External dependencies
+ */
+import ReactDOM from 'react-dom';
+import { ExpressCheckoutElement, Elements } from '@stripe/react-stripe-js';
+import { memoize } from 'lodash';
+
+/**
+ * Internal dependencies
+ */
+import { isLinkEnabled } from 'wcpay/checkout/utils/upe';
+import request from 'wcpay/checkout/utils/request';
+import WCPayAPI from 'wcpay/checkout/api';
+import { getUPEConfig } from 'wcpay/utils/checkout';
+
+export const checkPaymentMethodIsAvailable = memoize(
+ ( paymentMethod, cart, resolve ) => {
+ // Create the DIV container on the fly
+ const containerEl = document.createElement( 'div' );
+
+ // Ensure the element is hidden and doesn’t interfere with the page layout.
+ containerEl.style.display = 'none';
+
+ document.querySelector( 'body' ).appendChild( containerEl );
+
+ const root = ReactDOM.createRoot( containerEl );
+
+ const api = new WCPayAPI(
+ {
+ publishableKey: getUPEConfig( 'publishableKey' ),
+ accountId: getUPEConfig( 'accountId' ),
+ forceNetworkSavedCards: getUPEConfig(
+ 'forceNetworkSavedCards'
+ ),
+ locale: getUPEConfig( 'locale' ),
+ isStripeLinkEnabled: isLinkEnabled(
+ getUPEConfig( 'paymentMethodsConfig' )
+ ),
+ },
+ request
+ );
+
+ root.render(
+
+ resolve( false ) }
+ options={ {
+ paymentMethods: {
+ amazonPay: 'never',
+ applePay:
+ paymentMethod === 'applePay'
+ ? 'always'
+ : 'never',
+ googlePay:
+ paymentMethod === 'googlePay'
+ ? 'always'
+ : 'never',
+ link: 'never',
+ paypal: 'never',
+ },
+ } }
+ onReady={ ( event ) => {
+ let canMakePayment = false;
+ if ( event.availablePaymentMethods ) {
+ canMakePayment =
+ event.availablePaymentMethods[ paymentMethod ];
+ }
+ resolve( canMakePayment );
+ root.unmount();
+ containerEl.remove();
+ } }
+ />
+
+ );
+ }
+);
diff --git a/client/tokenized-express-checkout/utils/index.ts b/client/tokenized-express-checkout/utils/index.ts
new file mode 100644
index 00000000000..f6089857abc
--- /dev/null
+++ b/client/tokenized-express-checkout/utils/index.ts
@@ -0,0 +1,273 @@
+/**
+ * Internal dependencies
+ */
+export * from './normalize';
+import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout';
+
+interface MyWindow extends Window {
+ wcpayExpressCheckoutParams: WCPayExpressCheckoutParams;
+}
+
+declare let window: MyWindow;
+
+/**
+ * An /incomplete/ representation of the data that is loaded into the frontend for the Express Checkout.
+ */
+export interface WCPayExpressCheckoutParams {
+ ajax_url: string;
+
+ /**
+ * Express Checkout Button style configuration.
+ */
+ button: {
+ type: string;
+ theme: string;
+ height: string;
+ locale: string;
+ branded_type: string;
+ radius: number;
+ };
+
+ /**
+ * Indicates in which context the button is being displayed.
+ */
+ button_context: 'checkout' | 'cart' | 'product' | 'pay_for_order';
+ checkout: {
+ country_code: string;
+ currency_code: string;
+ needs_payer_phone: boolean;
+ needs_shipping: boolean;
+ };
+
+ /**
+ * Indicaters whether the page has a Cart or Checkout Block on it.
+ */
+ has_block: boolean;
+
+ /**
+ * True if we're on the checkout page.
+ */
+ is_checkout_page: boolean;
+
+ /**
+ * True if we're on a product page.
+ */
+ is_product_page: boolean;
+
+ /**
+ * True if we're on the pay for order page.
+ */
+ is_pay_for_order_page: boolean;
+ nonce: {
+ add_to_cart: string;
+ checkout: string;
+ empty_cart: string;
+ get_cart_details: string;
+ get_selected_product_data: string;
+ pay_for_order: string;
+ platform_tracker: string;
+ shipping: string;
+ update_shipping: string;
+ };
+
+ /**
+ * Product specific options.
+ */
+ product: {
+ needs_shipping: boolean;
+ currency: string;
+ shippingOptions: {
+ id: string;
+ label: string;
+ detail: string;
+ amount: number;
+ };
+ };
+
+ /**
+ * Settings for the user authentication dialog and redirection.
+ */
+ login_confirmation: { message: string; redirect_url: string } | false;
+
+ stripe: {
+ accountId: string;
+ locale: string;
+ publishableKey: string;
+ };
+ total_label: string;
+ wc_ajax_url: string;
+}
+
+declare global {
+ interface Window {
+ wcpayExpressCheckoutParams?: WCPayExpressCheckoutParams;
+ }
+}
+
+export const getExpressCheckoutData = <
+ K extends keyof WCPayExpressCheckoutParams
+>(
+ key: K
+) => {
+ if ( typeof window.wcpayExpressCheckoutParams !== 'undefined' ) {
+ return window.wcpayExpressCheckoutParams[ key ] ?? null;
+ } else if ( typeof window.wc?.wcSettings !== 'undefined' ) {
+ return window.wc.wcSettings.getSetting( 'ece_data' )?.[ key ] ?? null;
+ }
+
+ return null;
+};
+
+/**
+ * Get error messages from WooCommerce notice from server response.
+ *
+ * @param notice Error notice.
+ * @return Error messages.
+ */
+export const getErrorMessageFromNotice = ( notice: string ) => {
+ const div = document.createElement( 'div' );
+ div.innerHTML = notice.trim();
+ return div.firstChild ? div.firstChild.textContent : '';
+};
+
+type ExpressPaymentType =
+ | 'apple_pay'
+ | 'google_pay'
+ | 'amazon_pay'
+ | 'paypal'
+ | 'link';
+
+/**
+ * Displays a `confirm` dialog which leads to a redirect.
+ *
+ * @param expressPaymentType Can be either 'apple_pay', 'google_pay', 'amazon_pay', 'paypal' or 'link'.
+ */
+export const displayLoginConfirmation = (
+ expressPaymentType: ExpressPaymentType
+) => {
+ const loginConfirmation = getExpressCheckoutData( 'login_confirmation' );
+
+ if ( ! loginConfirmation ) {
+ return;
+ }
+
+ const paymentTypesMap = {
+ apple_pay: 'Apple Pay',
+ google_pay: 'Google Pay',
+ amazon_pay: 'Amazon Pay',
+ paypal: 'PayPal',
+ link: 'Link',
+ };
+ let message = loginConfirmation.message;
+
+ // Replace dialog text with specific express checkout type.
+ message = message.replace(
+ /\*\*.*?\*\*/,
+ paymentTypesMap[ expressPaymentType ]
+ );
+
+ // Remove asterisks from string.
+ message = message.replace( /\*\*/g, '' );
+
+ if ( confirm( message ) ) {
+ // Redirect to my account page.
+ window.location.href = loginConfirmation.redirect_url;
+ }
+};
+
+type ButtonAttributesType =
+ | { height: string; borderRadius: string }
+ | undefined;
+
+/**
+ * Returns the appearance settings for the Express Checkout buttons.
+ * Currently only configures border radius for the buttons.
+ */
+export const getExpressCheckoutButtonAppearance = (
+ buttonAttributes: ButtonAttributesType
+) => {
+ let borderRadius = getDefaultBorderRadius();
+ const buttonSettings = getExpressCheckoutData( 'button' );
+
+ // Border radius from WooPayments settings
+ borderRadius = buttonSettings?.radius ?? borderRadius;
+
+ // Border radius from Cart & Checkout blocks attributes
+ if ( typeof buttonAttributes !== 'undefined' ) {
+ borderRadius = Number( buttonAttributes?.borderRadius ) ?? borderRadius;
+ }
+
+ return {
+ variables: {
+ borderRadius: `${ borderRadius }px`,
+ spacingUnit: '6px',
+ },
+ };
+};
+
+/**
+ * Returns the style settings for the Express Checkout buttons.
+ */
+export const getExpressCheckoutButtonStyleSettings = () => {
+ const buttonSettings = getExpressCheckoutData( 'button' );
+
+ const mapWooPaymentsThemeToButtonTheme = (
+ buttonType: string,
+ theme: string
+ ) => {
+ switch ( theme ) {
+ case 'dark':
+ return 'black';
+ case 'light':
+ return 'white';
+ case 'light-outline':
+ if ( buttonType === 'googlePay' ) {
+ return 'white';
+ }
+
+ return 'white-outline';
+ default:
+ return 'black';
+ }
+ };
+
+ const googlePayType =
+ buttonSettings?.type === 'default'
+ ? 'plain'
+ : buttonSettings?.type ?? 'buy';
+
+ const applePayType =
+ buttonSettings?.type === 'default'
+ ? 'plain'
+ : buttonSettings?.type ?? 'plain';
+
+ return {
+ paymentMethods: {
+ applePay: 'always',
+ googlePay: 'always',
+ link: 'never',
+ paypal: 'never',
+ amazonPay: 'never',
+ },
+ layout: { overflow: 'never' },
+ buttonTheme: {
+ googlePay: mapWooPaymentsThemeToButtonTheme(
+ 'googlePay',
+ buttonSettings?.theme ?? 'black'
+ ),
+ applePay: mapWooPaymentsThemeToButtonTheme(
+ 'applePay',
+ buttonSettings?.theme ?? 'black'
+ ),
+ },
+ buttonType: {
+ googlePay: googlePayType,
+ applePay: applePayType,
+ },
+ // Allowed height must be 40px to 55px.
+ buttonHeight: Math.min(
+ Math.max( parseInt( buttonSettings?.height ?? '48', 10 ), 40 ),
+ 55
+ ),
+ };
+};
diff --git a/client/tokenized-express-checkout/utils/normalize.js b/client/tokenized-express-checkout/utils/normalize.js
new file mode 100644
index 00000000000..e55a44bfee9
--- /dev/null
+++ b/client/tokenized-express-checkout/utils/normalize.js
@@ -0,0 +1,123 @@
+/**
+ * Normalizes incoming cart total items for use as a displayItems with the Stripe api.
+ *
+ * @param {Array} displayItems Items to normalize.
+ * @param {boolean} pending Whether to mark items as pending or not.
+ *
+ * @return {Array} An array of PaymentItems
+ */
+export const normalizeLineItems = ( displayItems ) => {
+ return displayItems.map( ( displayItem ) => {
+ let amount = displayItem?.amount ?? displayItem?.value;
+ if ( displayItem.key === 'total_discount' ) {
+ amount = -amount;
+ }
+
+ return {
+ name: displayItem.label,
+ amount,
+ };
+ } );
+};
+
+/**
+ * Normalize order data from Stripe's object to the expected format for WC.
+ *
+ * @param {Object} event Stripe's event object.
+ * @param {string} paymentMethodId Stripe's payment method id.
+ *
+ * @return {Object} Order object in the format WooCommerce expects.
+ */
+export const normalizeOrderData = ( event, paymentMethodId ) => {
+ const name = event?.billingDetails?.name;
+ const email = event?.billingDetails?.email ?? '';
+ const billing = event?.billingDetails?.address ?? {};
+ const shipping = event?.shippingAddress ?? {};
+ const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? '';
+
+ const phone =
+ event?.billingDetails?.phone?.replace( /[() -]/g, '' ) ??
+ event?.payerPhone?.replace( /[() -]/g, '' ) ??
+ '';
+
+ return {
+ billing_first_name:
+ name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '',
+ billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '-',
+ billing_company: billing?.organization ?? '',
+ billing_email: email ?? event?.payerEmail ?? '',
+ billing_phone: phone,
+ billing_country: billing?.country ?? '',
+ billing_address_1: billing?.line1 ?? '',
+ billing_address_2: billing?.line2 ?? '',
+ billing_city: billing?.city ?? '',
+ billing_state: billing?.state ?? '',
+ billing_postcode: billing?.postal_code ?? '',
+ shipping_first_name:
+ shipping?.name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '',
+ shipping_last_name:
+ shipping?.name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '',
+ shipping_company: shipping?.organization ?? '',
+ shipping_phone: phone,
+ shipping_country: shipping?.address?.country ?? '',
+ shipping_address_1: shipping?.address?.line1 ?? '',
+ shipping_address_2: shipping?.address?.line2 ?? '',
+ shipping_city: shipping?.address?.city ?? '',
+ shipping_state: shipping?.address?.state ?? '',
+ shipping_postcode: shipping?.address?.postal_code ?? '',
+ shipping_method: [ event?.shippingRate?.id ?? null ],
+ order_comments: '',
+ payment_method: 'woocommerce_payments',
+ ship_to_different_address: 1,
+ terms: 1,
+ 'wcpay-payment-method': paymentMethodId,
+ payment_request_type: event?.expressPaymentType,
+ express_payment_type: event?.expressPaymentType,
+ 'wcpay-fraud-prevention-token': fraudPreventionTokenValue,
+ };
+};
+
+/**
+ * Normalize Pay for Order data from Stripe's object to the expected format for WC.
+ *
+ * @param {Object} event Stripe's event object.
+ * @param {string} paymentMethodId Stripe's payment method id.
+ *
+ * @return {Object} Order object in the format WooCommerce expects.
+ */
+export const normalizePayForOrderData = ( event, paymentMethodId ) => {
+ return {
+ payment_method: 'woocommerce_payments',
+ 'wcpay-payment-method': paymentMethodId,
+ express_payment_type: event?.expressPaymentType,
+ 'wcpay-fraud-prevention-token': window.wcpayFraudPreventionToken ?? '',
+ };
+};
+
+/**
+ * Normalize shipping address information from Stripe's address object to
+ * the cart shipping address object shape.
+ *
+ * @param {Object} shippingAddress Stripe's shipping address item
+ *
+ * @return {Object} The shipping address in the shape expected by the cart.
+ */
+export const normalizeShippingAddress = ( shippingAddress ) => {
+ return {
+ first_name:
+ shippingAddress?.recipient
+ ?.split( ' ' )
+ ?.slice( 0, 1 )
+ ?.join( ' ' ) ?? '',
+ last_name:
+ shippingAddress?.recipient?.split( ' ' )?.slice( 1 )?.join( ' ' ) ??
+ '',
+ company: '',
+ address_1: shippingAddress?.addressLine?.[ 0 ] ?? '',
+ address_2: shippingAddress?.addressLine?.[ 1 ] ?? '',
+ city: shippingAddress?.city ?? '',
+ state: shippingAddress?.state ?? '',
+ country: shippingAddress?.country ?? '',
+ postcode: shippingAddress?.postal_code ?? '',
+ };
+};
diff --git a/client/tokenized-express-checkout/utils/test/index.ts b/client/tokenized-express-checkout/utils/test/index.ts
new file mode 100644
index 00000000000..e1e61edf988
--- /dev/null
+++ b/client/tokenized-express-checkout/utils/test/index.ts
@@ -0,0 +1,44 @@
+/**
+ * Internal dependencies
+ */
+import {
+ WCPayExpressCheckoutParams,
+ getErrorMessageFromNotice,
+ getExpressCheckoutData,
+} from '../index';
+
+describe( 'Express checkout utils', () => {
+ test( 'getExpressCheckoutData returns null for missing option', () => {
+ expect(
+ getExpressCheckoutData(
+ // Force wrong usage, just in case this is called from JS with incorrect params.
+ 'does-not-exist' as keyof WCPayExpressCheckoutParams
+ )
+ ).toBeNull();
+ } );
+
+ test( 'getExpressCheckoutData returns correct value for present option', () => {
+ // We don't care that the implementation is partial for the purposes of the test, so
+ // the type assertion is fine.
+ window.wcpayExpressCheckoutParams = {
+ ajax_url: 'test',
+ } as WCPayExpressCheckoutParams;
+
+ expect( getExpressCheckoutData( 'ajax_url' ) ).toBe( 'test' );
+ } );
+
+ test( 'getErrorMessageFromNotice strips formatting', () => {
+ const notice = 'Error: Payment failed.
';
+ expect( getErrorMessageFromNotice( notice ) ).toBe(
+ 'Error: Payment failed.'
+ );
+ } );
+
+ test( 'getErrorMessageFromNotice strips scripts', () => {
+ const notice =
+ 'Error: Payment failed.
';
+ expect( getErrorMessageFromNotice( notice ) ).toBe(
+ 'Error: Payment failed.alert("hello")'
+ );
+ } );
+} );
diff --git a/client/tokenized-express-checkout/utils/test/normalize.js b/client/tokenized-express-checkout/utils/test/normalize.js
new file mode 100644
index 00000000000..e963a356911
--- /dev/null
+++ b/client/tokenized-express-checkout/utils/test/normalize.js
@@ -0,0 +1,510 @@
+/**
+ * Internal dependencies
+ */
+import {
+ normalizeLineItems,
+ normalizeOrderData,
+ normalizePayForOrderData,
+ normalizeShippingAddress,
+} from '../normalize';
+
+describe( 'Express checkout normalization', () => {
+ describe( 'normalizeLineItems', () => {
+ test( 'normalizes blocks array properly', () => {
+ const displayItems = [
+ {
+ label: 'Item 1',
+ value: 100,
+ },
+ {
+ label: 'Item 2',
+ value: 200,
+ },
+ {
+ label: 'Item 3',
+ valueWithTax: 300,
+ value: 200,
+ },
+ ];
+
+ // Extra items in the array are expected since they're not stripped.
+ const expected = [
+ {
+ name: 'Item 1',
+ amount: 100,
+ },
+ {
+ name: 'Item 2',
+ amount: 200,
+ },
+ {
+ name: 'Item 3',
+ amount: 200,
+ },
+ ];
+
+ expect( normalizeLineItems( displayItems ) ).toStrictEqual(
+ expected
+ );
+ } );
+
+ test( 'normalizes shortcode array properly', () => {
+ const displayItems = [
+ {
+ label: 'Item 1',
+ amount: 100,
+ },
+ {
+ label: 'Item 2',
+ amount: 200,
+ },
+ {
+ label: 'Item 3',
+ amount: 300,
+ },
+ ];
+
+ const expected = [
+ {
+ name: 'Item 1',
+ amount: 100,
+ },
+ {
+ name: 'Item 2',
+ amount: 200,
+ },
+ {
+ name: 'Item 3',
+ amount: 300,
+ },
+ ];
+
+ expect( normalizeLineItems( displayItems ) ).toStrictEqual(
+ expected
+ );
+ } );
+
+ test( 'normalizes discount line item properly', () => {
+ const displayItems = [
+ {
+ label: 'Item 1',
+ amount: 100,
+ },
+ {
+ label: 'Item 2',
+ amount: 200,
+ },
+ {
+ label: 'Item 3',
+ amount: 300,
+ },
+ {
+ key: 'total_discount',
+ label: 'Discount',
+ amount: 50,
+ },
+ ];
+
+ const expected = [
+ {
+ name: 'Item 1',
+ amount: 100,
+ },
+ {
+ name: 'Item 2',
+ amount: 200,
+ },
+ {
+ name: 'Item 3',
+ amount: 300,
+ },
+ {
+ name: 'Discount',
+ amount: -50,
+ },
+ ];
+
+ expect( normalizeLineItems( displayItems ) ).toStrictEqual(
+ expected
+ );
+ } );
+ } );
+
+ describe( 'normalizeOrderData', () => {
+ afterEach( () => {
+ // Clear any changes to the fraud prevention token.
+ delete window.wcpayFraudPreventionToken;
+ } );
+
+ test( 'should normalize order data with complete event and paymentMethodId', () => {
+ window.wcpayFraudPreventionToken = 'token123';
+
+ const event = {
+ billingDetails: {
+ name: 'John Doe',
+ email: 'john.doe@example.com',
+ address: {
+ organization: 'Some Company',
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ phone: '(123) 456-7890',
+ },
+ shippingAddress: {
+ name: 'John Doe',
+ organization: 'Some Company',
+ address: {
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ },
+ shippingRate: { id: 'rate_1' },
+ expressPaymentType: 'express',
+ };
+
+ const paymentMethodId = 'pm_123456';
+
+ const expectedNormalizedData = {
+ billing_first_name: 'John',
+ billing_last_name: 'Doe',
+ billing_company: 'Some Company',
+ billing_email: 'john.doe@example.com',
+ billing_phone: '1234567890',
+ billing_country: 'US',
+ billing_address_1: '123 Main St',
+ billing_address_2: 'Apt 4B',
+ billing_city: 'New York',
+ billing_state: 'NY',
+ billing_postcode: '10001',
+ shipping_first_name: 'John',
+ shipping_last_name: 'Doe',
+ shipping_company: 'Some Company',
+ shipping_phone: '1234567890',
+ shipping_country: 'US',
+ shipping_address_1: '123 Main St',
+ shipping_address_2: 'Apt 4B',
+ shipping_city: 'New York',
+ shipping_state: 'NY',
+ shipping_postcode: '10001',
+ shipping_method: [ 'rate_1' ],
+ order_comments: '',
+ payment_method: 'woocommerce_payments',
+ ship_to_different_address: 1,
+ terms: 1,
+ 'wcpay-payment-method': paymentMethodId,
+ payment_request_type: 'express',
+ express_payment_type: 'express',
+ 'wcpay-fraud-prevention-token': 'token123',
+ };
+
+ expect( normalizeOrderData( event, paymentMethodId ) ).toEqual(
+ expectedNormalizedData
+ );
+ } );
+
+ test( 'should normalize order data with missing optional event fields', () => {
+ const event = {};
+ const paymentMethodId = 'pm_123456';
+
+ const expectedNormalizedData = {
+ billing_first_name: '',
+ billing_last_name: '-',
+ billing_company: '',
+ billing_email: '',
+ billing_phone: '',
+ billing_country: '',
+ billing_address_1: '',
+ billing_address_2: '',
+ billing_city: '',
+ billing_state: '',
+ billing_postcode: '',
+ shipping_first_name: '',
+ shipping_last_name: '',
+ shipping_company: '',
+ shipping_phone: '',
+ shipping_country: '',
+ shipping_address_1: '',
+ shipping_address_2: '',
+ shipping_city: '',
+ shipping_state: '',
+ shipping_postcode: '',
+ shipping_method: [ null ],
+ order_comments: '',
+ payment_method: 'woocommerce_payments',
+ ship_to_different_address: 1,
+ terms: 1,
+ 'wcpay-payment-method': paymentMethodId,
+ payment_request_type: undefined,
+ express_payment_type: undefined,
+ 'wcpay-fraud-prevention-token': '',
+ };
+
+ expect( normalizeOrderData( event, paymentMethodId ) ).toEqual(
+ expectedNormalizedData
+ );
+ } );
+
+ test( 'should normalize order data with minimum required fields', () => {
+ const event = {
+ billingDetails: {
+ name: 'John',
+ },
+ };
+ const paymentMethodId = 'pm_123456';
+
+ const expectedNormalizedData = {
+ billing_first_name: 'John',
+ billing_last_name: '',
+ billing_company: '',
+ billing_email: '',
+ billing_phone: '',
+ billing_country: '',
+ billing_address_1: '',
+ billing_address_2: '',
+ billing_city: '',
+ billing_state: '',
+ billing_postcode: '',
+ shipping_first_name: '',
+ shipping_last_name: '',
+ shipping_company: '',
+ shipping_phone: '',
+ shipping_country: '',
+ shipping_address_1: '',
+ shipping_address_2: '',
+ shipping_city: '',
+ shipping_state: '',
+ shipping_postcode: '',
+ shipping_method: [ null ],
+ order_comments: '',
+ payment_method: 'woocommerce_payments',
+ ship_to_different_address: 1,
+ terms: 1,
+ 'wcpay-payment-method': paymentMethodId,
+ payment_request_type: undefined,
+ express_payment_type: undefined,
+ 'wcpay-fraud-prevention-token': '',
+ };
+
+ expect( normalizeOrderData( event, paymentMethodId ) ).toEqual(
+ expectedNormalizedData
+ );
+ } );
+ } );
+
+ describe( 'normalizePayForOrderData', () => {
+ test( 'should normalize pay for order data with complete event and paymentMethodId', () => {
+ window.wcpayFraudPreventionToken = 'token123';
+
+ const event = {
+ billingDetails: {
+ name: 'John Doe',
+ email: 'john.doe@example.com',
+ address: {
+ organization: 'Some Company',
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ phone: '(123) 456-7890',
+ },
+ shippingAddress: {
+ name: 'John Doe',
+ organization: 'Some Company',
+ address: {
+ country: 'US',
+ line1: '123 Main St',
+ line2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ postal_code: '10001',
+ },
+ },
+ shippingRate: { id: 'rate_1' },
+ expressPaymentType: 'express',
+ };
+
+ expect( normalizePayForOrderData( event, 'pm_123456' ) ).toEqual( {
+ payment_method: 'woocommerce_payments',
+ 'wcpay-payment-method': 'pm_123456',
+ 'wcpay-fraud-prevention-token': 'token123',
+ express_payment_type: 'express',
+ } );
+ } );
+
+ test( 'should normalize pay for order data with empty event and empty payment method', () => {
+ const event = {};
+ const paymentMethodId = '';
+
+ expect(
+ normalizePayForOrderData( event, paymentMethodId )
+ ).toEqual( {
+ payment_method: 'woocommerce_payments',
+ 'wcpay-payment-method': '',
+ 'wcpay-fraud-prevention-token': 'token123',
+ express_payment_type: undefined,
+ } );
+ } );
+ } );
+
+ describe( 'normalizeShippingAddress', () => {
+ test( 'should normalize shipping address with all fields present', () => {
+ const shippingAddress = {
+ recipient: 'John Doe',
+ addressLine: [ '123 Main St', 'Apt 4B' ],
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postal_code: '10001',
+ };
+
+ const expectedNormalizedAddress = {
+ first_name: 'John',
+ last_name: 'Doe',
+ company: '',
+ address_1: '123 Main St',
+ address_2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postcode: '10001',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+
+ test( 'should normalize shipping address with only recipient name', () => {
+ const shippingAddress = {
+ recipient: 'John',
+ };
+
+ const expectedNormalizedAddress = {
+ first_name: 'John',
+ last_name: '',
+ company: '',
+ address_1: '',
+ address_2: '',
+ city: '',
+ state: '',
+ country: '',
+ postcode: '',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+
+ test( 'should normalize shipping address with missing recipient name', () => {
+ const shippingAddress = {
+ addressLine: [ '123 Main St' ],
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postal_code: '10001',
+ };
+
+ const expectedNormalizedAddress = {
+ first_name: '',
+ last_name: '',
+ company: '',
+ address_1: '123 Main St',
+ address_2: '',
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postcode: '10001',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+
+ test( 'should normalize shipping address with empty addressLine', () => {
+ const shippingAddress = {
+ recipient: 'John Doe',
+ addressLine: [],
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postal_code: '10001',
+ };
+
+ const expectedNormalizedAddress = {
+ first_name: 'John',
+ last_name: 'Doe',
+ company: '',
+ address_1: '',
+ address_2: '',
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postcode: '10001',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+
+ test( 'should normalize an empty shipping address', () => {
+ const shippingAddress = {};
+
+ const expectedNormalizedAddress = {
+ first_name: '',
+ last_name: '',
+ company: '',
+ address_1: '',
+ address_2: '',
+ city: '',
+ state: '',
+ country: '',
+ postcode: '',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+
+ test( 'should normalize a shipping address with a multi-word recipient name', () => {
+ const shippingAddress = {
+ recipient: 'John Doe Smith',
+ addressLine: [ '123 Main St', 'Apt 4B' ],
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postal_code: '10001',
+ };
+
+ const expectedNormalizedAddress = {
+ first_name: 'John',
+ last_name: 'Doe Smith',
+ company: '',
+ address_1: '123 Main St',
+ address_2: 'Apt 4B',
+ city: 'New York',
+ state: 'NY',
+ country: 'US',
+ postcode: '10001',
+ };
+
+ expect( normalizeShippingAddress( shippingAddress ) ).toEqual(
+ expectedNormalizedAddress
+ );
+ } );
+ } );
+} );
diff --git a/client/tokenized-payment-request/README.md b/client/tokenized-payment-request/README.md
new file mode 100644
index 00000000000..2c92ba8d2ff
--- /dev/null
+++ b/client/tokenized-payment-request/README.md
@@ -0,0 +1,4 @@
+# Tokenized Payment Request Button
+
+This directory contains the JS work done by the Heisenberg team to convert the PRBs to leverage the Store API.
+We'll delete the directory and its contents as part of https://github.com/Automattic/woocommerce-payments/issues/9722 .
diff --git a/includes/class-wc-payments-blocks-payment-method.php b/includes/class-wc-payments-blocks-payment-method.php
index 275b3bc6a94..92184696f31 100644
--- a/includes/class-wc-payments-blocks-payment-method.php
+++ b/includes/class-wc-payments-blocks-payment-method.php
@@ -71,13 +71,7 @@ public function get_payment_method_script_handles() {
true
);
- $script_dependencies = [ 'stripe' ];
-
- if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() && ( is_cart() || is_checkout() || is_product() || has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) ) ) {
- $script_dependencies[] = 'WCPAY_PAYMENT_REQUEST';
- }
-
- WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', $script_dependencies );
+ WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', [ 'stripe' ] );
wp_set_script_translations( 'WCPAY_BLOCKS_CHECKOUT', 'woocommerce-payments' );
diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php
index 3df89a40d72..c9dcdbc1ee3 100644
--- a/includes/class-wc-payments-payment-request-button-handler.php
+++ b/includes/class-wc-payments-payment-request-button-handler.php
@@ -6,6 +6,7 @@
*
* Adapted from WooCommerce Stripe Gateway extension.
*
+ * @deprecated We'll delete this class as part of https://github.com/Automattic/woocommerce-payments/issues/9722 .
* @package WooCommerce\Payments
*/
@@ -17,10 +18,11 @@
use WCPay\Exceptions\Invalid_Price_Exception;
use WCPay\Fraud_Prevention\Fraud_Prevention_Service;
use WCPay\Logger;
-use WCPay\Payment_Information;
/**
* WC_Payments_Payment_Request_Button_Handler class.
+ *
+ * @deprecated We'll delete this class as part of https://github.com/Automattic/woocommerce-payments/issues/9722 .
*/
class WC_Payments_Payment_Request_Button_Handler {
const BUTTON_LOCATIONS = 'payment_request_button_locations';
diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php
index 17393c38dd9..1e1e167aa84 100644
--- a/includes/class-wc-payments.php
+++ b/includes/class-wc-payments.php
@@ -442,7 +442,6 @@ public static function init() {
include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-ajax-handler.php';
include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-display-handler.php';
include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-handler.php';
- include_once __DIR__ . '/class-wc-payments-payment-request-button-handler.php';
include_once __DIR__ . '/class-wc-payments-woopay-button-handler.php';
include_once __DIR__ . '/class-wc-payments-woopay-direct-checkout.php';
include_once __DIR__ . '/class-wc-payments-apple-pay-registration.php';
@@ -1704,12 +1703,11 @@ public static function maybe_enqueue_woopay_common_config_script( $should_enqueu
*/
public static function maybe_display_express_checkout_buttons() {
if ( WC_Payments_Features::are_payments_enabled() ) {
- $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() );
- $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() );
+ $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() );
$express_checkout_ajax_handler = new WC_Payments_Express_Checkout_Ajax_Handler( self::get_express_checkout_helper() );
$express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper(), $express_checkout_ajax_handler );
- $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, $express_checkout_ajax_handler, self::get_express_checkout_helper() );
+ $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $woopay_button_handler, $express_checkout_element_button_handler, $express_checkout_ajax_handler, self::get_express_checkout_helper() );
$express_checkout_button_display_handler->init();
}
}
diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php
index 319c28b89a6..4472fb1067e 100644
--- a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php
+++ b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php
@@ -21,13 +21,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler {
*/
private $gateway;
- /**
- * Instance of WC_Payments_Payment_Request_Button_Handler, created in init function
- *
- * @var WC_Payments_Payment_Request_Button_Handler
- */
- private $payment_request_button_handler;
-
/**
* Instance of WC_Payments_WooPay_Button_Handler, created in init function
*
@@ -60,7 +53,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler {
* Initialize class actions.
*
* @param WC_Payment_Gateway_WCPay $gateway WCPay gateway.
- * @param WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler Payment request button handler.
* @param WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler Platform checkout button handler.
* @param WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler Express Checkout Element button handler.
* @param WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler Express checkout ajax handlers.
@@ -68,14 +60,12 @@ class WC_Payments_Express_Checkout_Button_Display_Handler {
*/
public function __construct(
WC_Payment_Gateway_WCPay $gateway,
- WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler,
WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler,
WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler,
WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler,
WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper
) {
$this->gateway = $gateway;
- $this->payment_request_button_handler = $payment_request_button_handler;
$this->platform_checkout_button_handler = $platform_checkout_button_handler;
$this->express_checkout_button_handler = $express_checkout_button_handler;
$this->express_checkout_ajax_handler = $express_checkout_ajax_handler;
@@ -89,7 +79,6 @@ public function __construct(
*/
public function init() {
$this->platform_checkout_button_handler->init();
- $this->payment_request_button_handler->init();
$this->express_checkout_button_handler->init();
$is_woopay_enabled = WC_Payments_Features::is_woopay_enabled();
@@ -130,13 +119,12 @@ public function display_express_checkout_separator_if_necessary( $separator_star
*/
public function display_express_checkout_buttons() {
$should_show_woopay = $this->platform_checkout_button_handler->should_show_woopay_button();
- $should_show_payment_request = $this->payment_request_button_handler->should_show_payment_request_button();
$should_show_express_checkout_button = $this->express_checkout_helper->should_show_express_checkout_button();
// When Payment Request button is enabled, we need the separator markup on the page, but hidden in case the browser doesn't have any payment request methods to display.
// More details: https://github.com/Automattic/woocommerce-payments/pull/5399#discussion_r1073633776.
$separator_starts_hidden = ! $should_show_woopay;
- if ( $should_show_woopay || $should_show_payment_request || $should_show_express_checkout_button ) {
+ if ( $should_show_woopay || $should_show_express_checkout_button ) {
?>
platform_checkout_button_handler->display_woopay_button_html();
}
- if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) {
- $this->payment_request_button_handler->display_payment_request_button_html();
- } else {
- $this->express_checkout_button_handler->display_express_checkout_button_html();
- }
+ $this->express_checkout_button_handler->display_express_checkout_button_html();
if ( is_cart() ) {
add_action( 'woocommerce_after_cart', [ $this, 'add_order_attribution_inputs' ], 1 );
diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php
index 3466978a474..6a0113d2048 100644
--- a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php
+++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php
@@ -260,14 +260,39 @@ public function scripts() {
'is_checkout_page' => $this->express_checkout_helper->is_checkout(),
];
- WC_Payments::register_script_with_dependencies( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'dist/express-checkout', [ 'jquery', 'stripe' ] );
+ if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) {
+ WC_Payments::register_script_with_dependencies(
+ 'WCPAY_EXPRESS_CHECKOUT_ECE',
+ 'dist/tokenized-express-checkout',
+ [
+ 'jquery',
+ 'stripe',
+ ]
+ );
- WC_Payments_Utils::enqueue_style(
- 'WCPAY_EXPRESS_CHECKOUT_ECE',
- plugins_url( 'dist/express-checkout.css', WCPAY_PLUGIN_FILE ),
- [],
- WC_Payments::get_file_version( 'dist/express-checkout.css' )
- );
+ WC_Payments_Utils::enqueue_style(
+ 'WCPAY_EXPRESS_CHECKOUT_ECE',
+ plugins_url( 'dist/tokenized-express-checkout.css', WCPAY_PLUGIN_FILE ),
+ [],
+ WC_Payments::get_file_version( 'dist/tokenized-express-checkout.css' )
+ );
+ } else {
+ WC_Payments::register_script_with_dependencies(
+ 'WCPAY_EXPRESS_CHECKOUT_ECE',
+ 'dist/express-checkout',
+ [
+ 'jquery',
+ 'stripe',
+ ]
+ );
+
+ WC_Payments_Utils::enqueue_style(
+ 'WCPAY_EXPRESS_CHECKOUT_ECE',
+ plugins_url( 'dist/express-checkout.css', WCPAY_PLUGIN_FILE ),
+ [],
+ WC_Payments::get_file_version( 'dist/express-checkout.css' )
+ );
+ }
wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $payment_request_params );
@@ -462,10 +487,11 @@ private function register_ece_data_for_block_editor() {
return;
}
- $ece_data = [
- 'button' => $this->get_button_settings(),
- ];
-
- $data_registry->add( 'ece_data', $ece_data );
+ $data_registry->add(
+ 'ece_data',
+ [
+ 'button' => $this->get_button_settings(),
+ ]
+ );
}
}
diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php
index 89ef79bfb11..99f99b071c2 100755
--- a/tests/unit/bootstrap.php
+++ b/tests/unit/bootstrap.php
@@ -96,6 +96,8 @@ function () {
require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-customer-controller.php';
require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-refunds-controller.php';
+ require_once $_plugin_dir . 'includes/class-wc-payments-payment-request-button-handler.php';
+
// Load currency helper class early to ensure its implementation is used over the one resolved during further test initialization.
require_once __DIR__ . '/helpers/class-wc-helper-site-currency.php';
diff --git a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php
index 98f7469de84..aa3d819faa1 100644
--- a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php
+++ b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php
@@ -37,13 +37,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler_Test extends WCPAY_Uni
*/
private $mock_woopay_button_handler;
- /**
- * Payment Request Button Handler mock instance.
- *
- * @var WC_Payments_Payment_Request_Button_Handler|MockObject
- */
- private $mock_payment_request_button_handler;
-
/**
* WC_Payments_Account instance.
*
@@ -150,22 +143,6 @@ public function set_up() {
)
->getMock();
- $this->mock_payment_request_button_handler = $this->getMockBuilder( WC_Payments_Payment_Request_Button_Handler::class )
- ->setConstructorArgs(
- [
- $this->mock_wcpay_account,
- $this->mock_wcpay_gateway,
- $this->mock_express_checkout_helper,
- ]
- )
- ->setMethods(
- [
- 'should_show_payment_request_button',
- 'is_checkout',
- ]
- )
- ->getMock();
-
$this->mock_express_checkout_ece_button_handler = $this->getMockBuilder( WC_Payments_Express_Checkout_Button_Handler::class )
->setConstructorArgs(
[
@@ -184,7 +161,6 @@ public function set_up() {
$this->express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler(
$this->mock_wcpay_gateway,
- $this->mock_payment_request_button_handler,
$this->mock_woopay_button_handler,
$this->mock_express_checkout_ece_button_handler,
$this->mock_express_checkout_ajax_handler,
@@ -267,10 +243,6 @@ public function test_display_express_checkout_buttons_all_disabled() {
->method( 'should_show_woopay_button' )
->willReturn( false );
- $this->mock_payment_request_button_handler
- ->method( 'should_show_payment_request_button' )
- ->willReturn( false );
-
$this->mock_express_checkout_helper
->method( 'is_checkout' )
->willReturn( false );
@@ -287,10 +259,6 @@ public function test_display_express_checkout_buttons_only_woopay() {
->method( 'should_show_woopay_button' )
->willReturn( true );
- $this->mock_payment_request_button_handler
- ->method( 'should_show_payment_request_button' )
- ->willReturn( false );
-
$this->mock_express_checkout_helper
->method( 'is_checkout' )
->willReturn( false );
@@ -302,25 +270,4 @@ public function test_display_express_checkout_buttons_only_woopay() {
$this->assertStringNotContainsString( 'wcpay-express-checkout-button-separator', ob_get_contents() );
ob_end_clean();
}
-
- public function test_display_express_checkout_buttons_only_payment_request() {
- $this->mock_woopay_button_handler
- ->method( 'should_show_woopay_button' )
- ->willReturn( false );
-
- $this->mock_payment_request_button_handler
- ->method( 'should_show_payment_request_button' )
- ->willReturn( true );
-
- $this->mock_express_checkout_helper
- ->method( 'is_checkout' )
- ->willReturn( true );
-
- ob_start();
- $this->express_checkout_button_display_handler->display_express_checkout_buttons();
-
- $this->assertStringContainsString( 'wcpay-express-checkout-button-separator', ob_get_contents() );
- $this->assertStringContainsString( 'display:none;', ob_get_contents() );
- ob_end_clean();
- }
}
diff --git a/tests/unit/test-class-wc-payments-payment-request-button-handler.php b/tests/unit/test-class-wc-payments-payment-request-button-handler.php
index bf9e6ab3eab..993534a2530 100644
--- a/tests/unit/test-class-wc-payments-payment-request-button-handler.php
+++ b/tests/unit/test-class-wc-payments-payment-request-button-handler.php
@@ -13,6 +13,7 @@
/**
* WC_Payments_Payment_Request_Button_Handler_Test class.
+ * @deprecated We'll delete this as part of https://github.com/Automattic/woocommerce-payments/issues/9722 .
*/
class WC_Payments_Payment_Request_Button_Handler_Test extends WCPAY_UnitTestCase {
const SHIPPING_ADDRESS = [
diff --git a/webpack/shared.js b/webpack/shared.js
index f338eaf4426..2dce99ca3ec 100644
--- a/webpack/shared.js
+++ b/webpack/shared.js
@@ -20,8 +20,8 @@ module.exports = {
cart: './client/cart/index.js',
checkout: './client/checkout/classic/event-handlers.js',
'express-checkout': './client/express-checkout/index.js',
- 'tokenized-payment-request':
- './client/tokenized-payment-request/index.js',
+ 'tokenized-express-checkout':
+ './client/tokenized-express-checkout/index.js',
'subscription-edit-page': './client/subscription-edit-page.js',
tos: './client/tos/index.js',
'payment-gateways': './client/payment-gateways/index.js',