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',