diff --git a/changelog/refactor-pdp-payment-request-tokenized-cart b/changelog/refactor-pdp-payment-request-tokenized-cart new file mode 100644 index 00000000000..97b25e0c121 --- /dev/null +++ b/changelog/refactor-pdp-payment-request-tokenized-cart @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +feat: tokenized cart PRBs on PDPs via feature flag. diff --git a/client/tokenized-payment-request/button-ui.js b/client/tokenized-payment-request/button-ui.js new file mode 100644 index 00000000000..dc8b4a0f978 --- /dev/null +++ b/client/tokenized-payment-request/button-ui.js @@ -0,0 +1,47 @@ +/* global jQuery */ + +let $wcpayPaymentRequestContainer = null; + +const paymentRequestButtonUi = { + init: ( { $container } ) => { + $wcpayPaymentRequestContainer = $container; + }, + + getElements: () => { + return jQuery( + '.wcpay-payment-request-wrapper,#wcpay-payment-request-button-separator' + ); + }, + + blockButton: () => { + // 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 ( $wcpayPaymentRequestContainer.data( 'blockUI.isBlocked' ) ) { + return; + } + + $wcpayPaymentRequestContainer.block( { message: null } ); + }, + + unblockButton: () => { + paymentRequestButtonUi.show(); + $wcpayPaymentRequestContainer.unblock(); + }, + + showButton: ( paymentRequestButton ) => { + if ( $wcpayPaymentRequestContainer.length ) { + paymentRequestButtonUi.show(); + paymentRequestButton.mount( '#wcpay-payment-request-button' ); + } + }, + + hide: () => { + paymentRequestButtonUi.getElements().hide(); + }, + + show: () => { + paymentRequestButtonUi.getElements().show(); + }, +}; + +export default paymentRequestButtonUi; diff --git a/client/tokenized-payment-request/cart-api.js b/client/tokenized-payment-request/cart-api.js new file mode 100644 index 00000000000..631a0705ad8 --- /dev/null +++ b/client/tokenized-payment-request/cart-api.js @@ -0,0 +1,200 @@ +/* global jQuery */ + +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { getPaymentRequestData } from './frontend-utils'; + +export default class PaymentRequestCartApi { + // Used on product pages to interact with an anonymous cart. + // This anonymous cart is separate from the customer's cart, which might contain additional products. + // This functionality is also useful to calculate product/shipping pricing (and shipping needs) + // for compatibility scenarios with other plugins (like WC Bookings, Product Add-Ons, WC Deposits, etc.). + cartRequestHeaders = {}; + + /** + * Creates an order from the cart object. + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/checkout.md#process-order-and-payment + * + * @param {{ + * billing_address: Object, + * shipping_address: Object, + * customer_note: string?, + * payment_method: string, + * payment_data: Array, + * }} paymentData Additional payment data to place the order. + * @return {Promise} Result of the order creation request. + */ + async placeOrder( paymentData ) { + return await apiFetch( { + method: 'POST', + path: '/wc/store/v1/checkout', + credentials: 'omit', + headers: { + 'X-WooPayments-Express-Payment-Request': true, + 'X-WooPayments-Express-Payment-Request-Nonce': + getPaymentRequestData( 'nonce' ).tokenized_cart_nonce || + undefined, + ...this.cartRequestHeaders, + }, + data: paymentData, + } ); + } + + /** + * Returns the customer's cart object. + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/cart.md#get-cart + * + * @return {Promise} Cart response object. + */ + async getCart() { + return await apiFetch( { + method: 'GET', + path: '/wc/store/v1/cart', + headers: { + ...this.cartRequestHeaders, + }, + } ); + } + + /** + * Creates and returns a new cart object. The response type is the same as `getCart()`. + * + * @return {Promise} Cart response object. + */ + async createAnonymousCart() { + const response = await apiFetch( { + method: 'GET', + path: '/wc/store/v1/cart', + // omitting credentials, to create a new cart object separate from the user's cart. + credentials: 'omit', + // parse: false to ensure we can get the response headers + parse: false, + } ); + + this.cartRequestHeaders = { + Nonce: response.headers.get( 'Nonce' ), + 'Cart-Token': response.headers.get( 'Cart-Token' ), + 'X-WooPayments-Express-Payment-Request-Nonce': response.headers.get( + 'X-WooPayments-Express-Payment-Request-Nonce' + ), + }; + } + + /** + * Update customer data and return the full cart response, or an error. + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/cart.md#update-customer + * + * @param {{ + * billing_address: Object?, + * shipping_address: Object?, + * }} customerData Customer data to update. + * @return {Promise} Cart Response on success, or an Error Response on failure. + */ + async updateCustomer( customerData ) { + return await apiFetch( { + method: 'POST', + path: '/wc/store/v1/cart/update-customer', + credentials: 'omit', + headers: { + 'X-WooPayments-Express-Payment-Request': true, + 'X-WooPayments-Express-Payment-Request-Nonce': + getPaymentRequestData( 'nonce' ).tokenized_cart_nonce || + undefined, + ...this.cartRequestHeaders, + }, + data: customerData, + } ); + } + + /** + * Selects an available shipping rate for a package, then returns the full cart response, or an error + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/cart.md#select-shipping-rate + * + * @param {{rate_id: string, package_id: integer}} shippingRate The selected shipping rate. + * @return {Promise} Cart Response on success, or an Error Response on failure. + */ + async selectShippingRate( shippingRate ) { + return await apiFetch( { + method: 'POST', + path: '/wc/store/v1/cart/select-shipping-rate', + credentials: 'omit', + headers: { + ...this.cartRequestHeaders, + }, + data: shippingRate, + } ); + } + + /** + * Add an item to the cart and return the full cart response, or an error. + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/cart.md#add-item + * + * @return {Promise} Cart Response on success, or an Error Response on failure. + */ + async addProductToCart() { + const productData = { + // can be modified in case of variable products, WC bookings plugin, etc. + id: jQuery( '.single_add_to_cart_button' ).val(), + quantity: parseInt( jQuery( '.quantity .qty' ).val(), 10 ) || 1, + // can be modified in case of variable products, WC bookings plugin, etc. + variation: [], + }; + + return await apiFetch( { + method: 'POST', + path: '/wc/store/v1/cart/add-item', + credentials: 'omit', + headers: { + ...this.cartRequestHeaders, + }, + data: applyFilters( + 'wcpay.payment-request.cart-add-item', + productData + ), + } ); + } + + /** + * Removes all items from the cart and clears the cart headers. + * See https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/StoreApi/docs/cart.md#remove-item + * + * @return {undefined} + */ + async emptyCart() { + try { + const cartData = await apiFetch( { + method: 'GET', + path: '/wc/store/v1/cart', + credentials: 'omit', + headers: { + ...this.cartRequestHeaders, + }, + } ); + + const removeItemsPromises = cartData.items.map( ( item ) => { + return apiFetch( { + method: 'POST', + path: '/wc/store/v1/cart/remove-item', + credentials: 'omit', + headers: { + ...this.cartRequestHeaders, + }, + data: { + key: item.key, + }, + } ); + } ); + + await Promise.all( removeItemsPromises ); + } catch ( e ) { + // let's ignore the error, it's likely not going to be relevant. + } + } +} diff --git a/client/tokenized-payment-request/compatibility/wc-deposits.js b/client/tokenized-payment-request/compatibility/wc-deposits.js new file mode 100644 index 00000000000..352b498b4b2 --- /dev/null +++ b/client/tokenized-payment-request/compatibility/wc-deposits.js @@ -0,0 +1,15 @@ +/* global jQuery */ +jQuery( ( $ ) => { + // WooCommerce Deposits support. + // Trigger the "woocommerce_variation_has_changed" event when the deposit option is changed. + $( 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' ).on( + 'change', + () => { + $( 'form' ) + .has( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ) + .trigger( 'woocommerce_variation_has_changed' ); + } + ); +} ); diff --git a/client/tokenized-payment-request/compatibility/wc-order-attribution.js b/client/tokenized-payment-request/compatibility/wc-order-attribution.js new file mode 100644 index 00000000000..a707f8330ab --- /dev/null +++ b/client/tokenized-payment-request/compatibility/wc-order-attribution.js @@ -0,0 +1,37 @@ +/* global jQuery */ + +/** + * External dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +addFilter( + 'wcpay.payment-request.cart-place-order-extension-data', + 'automattic/wcpay/payment-request', + ( extensionData ) => { + const orderAttributionValues = jQuery( + '#wcpay-express-checkout__order-attribution-inputs input' + ); + + if ( ! orderAttributionValues.length ) { + return extensionData; + } + + const orderAttributionData = {}; + orderAttributionValues.each( function () { + const name = jQuery( this ) + .attr( 'name' ) + .replace( 'wc_order_attribution_', '' ); + const value = jQuery( this ).val(); + + if ( name && value ) { + orderAttributionData[ name ] = value; + } + } ); + + return { + ...extensionData, + 'woocommerce/order-attribution': orderAttributionData, + }; + } +); diff --git a/client/tokenized-payment-request/compatibility/wc-product-variations.js b/client/tokenized-payment-request/compatibility/wc-product-variations.js new file mode 100644 index 00000000000..a20123e039a --- /dev/null +++ b/client/tokenized-payment-request/compatibility/wc-product-variations.js @@ -0,0 +1,80 @@ +/* global jQuery */ + +/** + * External dependencies + */ +import { addFilter, doAction } from '@wordpress/hooks'; +import paymentRequestButtonUi from '../button-ui'; +import { waitForAction } from '../frontend-utils'; + +jQuery( ( $ ) => { + $( document.body ).on( 'woocommerce_variation_has_changed', async () => { + try { + paymentRequestButtonUi.blockButton(); + + doAction( 'wcpay.payment-request.update-button-data' ); + await waitForAction( 'wcpay.payment-request.update-button-data' ); + + paymentRequestButtonUi.unblockButton(); + } catch ( e ) { + paymentRequestButtonUi.hide(); + } + } ); +} ); + +addFilter( + 'wcpay.payment-request.cart-add-item', + 'automattic/wcpay/payment-request', + ( productData ) => { + const $variationInformation = jQuery( '.single_variation_wrap' ); + if ( ! $variationInformation.length ) { + return productData; + } + + const productId = $variationInformation + .find( 'input[name="product_id"]' ) + .val(); + return { + ...productData, + id: parseInt( productId, 10 ), + }; + } +); +addFilter( + 'wcpay.payment-request.cart-add-item', + 'automattic/wcpay/payment-request', + ( productData ) => { + const $variationsForm = jQuery( '.variations_form' ); + if ( ! $variationsForm.length ) { + return productData; + } + + const attributes = []; + const $variationSelectElements = $variationsForm.find( + '.variations select' + ); + $variationSelectElements.each( function () { + const $select = jQuery( this ); + const attributeName = + $select.data( 'attribute_name' ) || $select.attr( 'name' ); + + attributes.push( { + // The Store API accepts the variable attribute's label, rather than an internal identifier: + // https://github.com/woocommerce/woocommerce-blocks/blob/trunk/src/StoreApi/docs/cart.md#add-item + // It's an unfortunate hack that doesn't work when labels have special characters in them. + attribute: document.querySelector( + `label[for="${ attributeName.replace( + 'attribute_', + '' + ) }"]` + ).innerHTML, + value: $select.val() || '', + } ); + } ); + + return { + ...productData, + variation: [ ...productData.variation, ...attributes ], + }; + } +); diff --git a/client/tokenized-payment-request/debounce.js b/client/tokenized-payment-request/debounce.js new file mode 100644 index 00000000000..5078309bff3 --- /dev/null +++ b/client/tokenized-payment-request/debounce.js @@ -0,0 +1,33 @@ +/** + * Creates a wrapper around a function that ensures a function can not be + * called in rapid succesion. The function can only be executed once and then again after + * the wait time has expired. Even if the wrapper is called multiple times, the wrapped + * function only excecutes once and then blocks until the wait time expires. + * + * @param {int} wait Milliseconds wait for the next time a function can be executed. + * @param {Function} func The function to be wrapped. + * @param {bool} immediate Overriding the wait time, will force the function to fire everytime. + * + * @return {Function} A wrapped function with execution limited by the wait time. + */ +const debounce = ( wait, func, immediate = false ) => { + let timeout; + return function () { + const context = this, + args = arguments; + const later = () => { + timeout = null; + if ( ! immediate ) { + func.apply( context, args ); + } + }; + const callNow = immediate && ! timeout; + clearTimeout( timeout ); + timeout = setTimeout( later, wait ); + if ( callNow ) { + func.apply( context, args ); + } + }; +}; + +export default debounce; diff --git a/client/tokenized-payment-request/frontend-utils.js b/client/tokenized-payment-request/frontend-utils.js new file mode 100644 index 00000000000..ece0344ad81 --- /dev/null +++ b/client/tokenized-payment-request/frontend-utils.js @@ -0,0 +1,119 @@ +/* global wcpayPaymentRequestParams */ +/** + * External dependencies + */ +import { doingAction } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { transformCartDataForDisplayItems } from './transformers/wc-to-stripe'; + +/** + * Retrieves payment request data from global variable. + * + * @param {string} key The object property key. + * @return {mixed} Value of the object prop or null. + */ +export const getPaymentRequestData = ( key ) => { + if ( + typeof wcpayPaymentRequestParams === 'object' && + wcpayPaymentRequestParams.hasOwnProperty( key ) + ) { + return wcpayPaymentRequestParams[ key ]; + } + return null; +}; + +/** + * Returns a Stripe payment request object. + * + * @param {Object} config A configuration object for getting the payment request. + * @return {Object} Payment Request options object + */ +export const getPaymentRequest = ( { stripe, cartData, productData } ) => { + // the country code defined here comes from the WC settings. + // It might be interesting to ensure the country code coincides with the Stripe account's country, + // as defined here: https://docs.stripe.com/js/payment_request/create + let country = getPaymentRequestData( 'checkout' )?.country_code; + + // Puerto Rico (PR) is the only US territory/possession that's supported by Stripe. + // Since it's considered a US state by Stripe, we need to do some special mapping. + if ( country === 'PR' ) { + country = 'US'; + } + + return stripe.paymentRequest( { + country, + requestPayerName: true, + requestPayerEmail: true, + requestPayerPhone: getPaymentRequestData( 'checkout' ) + ?.needs_payer_phone, + ...( productData + ? { + // we can't just pass `productData`, and we need a little bit of massaging for older data. + currency: productData.currency, + total: productData.total, + displayItems: productData.displayItems, + requestShipping: productData.needs_shipping, + } + : { + currency: cartData.totals.currency_code.toLowerCase(), + total: { + label: getPaymentRequestData( 'total_label' ), + amount: parseInt( cartData.totals.total_price, 10 ), + }, + requestShipping: + getPaymentRequestData( 'button_context' ) === + 'pay_for_order' + ? false + : cartData.needs_shipping, + displayItems: transformCartDataForDisplayItems( cartData ), + } ), + } ); +}; + +/** + * Displays a `confirm` dialog which leads to a redirect. + * + * @param {string} paymentRequestType Can be either apple_pay, google_pay or payment_request_api. + */ +export const displayLoginConfirmationDialog = ( paymentRequestType ) => { + if ( ! getPaymentRequestData( 'login_confirmation' ) ) { + return; + } + + let message = getPaymentRequestData( 'login_confirmation' )?.message; + + // Replace dialog text with specific payment request type "Apple Pay" or "Google Pay". + message = message.replace( + /\*\*.*?\*\*/, + paymentRequestType === 'apple_pay' ? 'Apple Pay' : 'Google Pay' + ); + + // Remove asterisks from string. + message = message.replace( /\*\*/g, '' ); + + if ( confirm( message ) ) { + // Redirect to my account page. + window.location.href = getPaymentRequestData( + 'login_confirmation' + )?.redirect_url; + } +}; + +/** + * Waiting for a specific WP action to finish completion. + * + * @param {string} hookName The name of the action to wait for. + * @return {Promise} Resolves when the action is completed. + */ +export const waitForAction = ( hookName ) => + new Promise( ( resolve ) => { + const interval = setInterval( () => { + if ( doingAction( hookName ) === false ) { + clearInterval( interval ); + resolve(); + } + }, 500 ); + } ); diff --git a/client/tokenized-payment-request/index.js b/client/tokenized-payment-request/index.js new file mode 100644 index 00000000000..19f78488477 --- /dev/null +++ b/client/tokenized-payment-request/index.js @@ -0,0 +1,80 @@ +/* global jQuery */ +/** + * External dependencies + */ +import { doAction } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import WCPayAPI from '../checkout/api'; +import PaymentRequestCartApi from './cart-api'; +import WooPaymentsPaymentRequest from './payment-request'; +import paymentRequestButtonUi from './button-ui'; +import { getPaymentRequestData } from './frontend-utils'; +import './compatibility/wc-deposits'; +import './compatibility/wc-order-attribution'; +import './compatibility/wc-product-variations'; + +import '../checkout/express-checkout-buttons.scss'; + +jQuery( ( $ ) => { + // Don't load if blocks checkout is being loaded. + if ( + getPaymentRequestData( 'has_block' ) && + getPaymentRequestData( 'button_context' ) !== 'pay_for_order' + ) { + return; + } + + const publishableKey = getPaymentRequestData( 'stripe' ).publishableKey; + + if ( ! publishableKey ) { + // If no configuration is present, we can't do anything. + return; + } + + // initializing the UI's container. + paymentRequestButtonUi.init( { + $container: $( '#wcpay-payment-request-button' ), + } ); + + const api = new WCPayAPI( + { + publishableKey, + accountId: getPaymentRequestData( 'stripe' ).accountId, + locale: getPaymentRequestData( 'stripe' ).locale, + }, + // A promise-based interface to jQuery.post. + ( url, args ) => { + return new Promise( ( resolve, reject ) => { + $.post( url, args ).then( resolve ).fail( reject ); + } ); + } + ); + const paymentRequestCartApi = new PaymentRequestCartApi(); + + const wooPaymentsPaymentRequest = new WooPaymentsPaymentRequest( { + wcpayApi: api, + paymentRequestCartApi, + productData: getPaymentRequestData( 'product' ) || undefined, + } ); + + // We don't need to initialize payment request on the checkout page now because it will be initialized by updated_checkout event. + if ( + getPaymentRequestData( 'button_context' ) !== 'checkout' || + getPaymentRequestData( 'button_context' ) === 'pay_for_order' + ) { + wooPaymentsPaymentRequest.init(); + } + + // We need to refresh payment request data when total is updated. + $( document.body ).on( 'updated_cart_totals', () => { + doAction( 'wcpay.payment-request.update-button-data' ); + } ); + + // We need to refresh payment request data when total is updated. + $( document.body ).on( 'updated_checkout', () => { + doAction( 'wcpay.payment-request.update-button-data' ); + } ); +} ); diff --git a/client/tokenized-payment-request/payment-request.js b/client/tokenized-payment-request/payment-request.js new file mode 100644 index 00000000000..4bf314b41b8 --- /dev/null +++ b/client/tokenized-payment-request/payment-request.js @@ -0,0 +1,417 @@ +/* global jQuery */ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + doAction, + addAction, + removeAction, + applyFilters, +} from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { + setPaymentRequestBranding, + trackPaymentRequestButtonClick, + trackPaymentRequestButtonLoad, +} from './tracking'; +import { + transformStripePaymentMethodForStoreApi, + transformStripeShippingAddressForStoreApi, +} from './transformers/stripe-to-wc'; +import { + transformCartDataForDisplayItems, + transformCartDataForShippingOptions, +} from './transformers/wc-to-stripe'; +import paymentRequestButtonUi from './button-ui'; +import { + getPaymentRequest, + displayLoginConfirmationDialog, + getPaymentRequestData, + waitForAction, +} from './frontend-utils'; +import PaymentRequestCartApi from './cart-api'; +import debounce from './debounce'; + +const noop = () => null; + +/** + * Class to handle Stripe payment forms. + */ +export default class WooPaymentsPaymentRequest { + /** + * Whether the payment was aborted by the customer. + */ + isPaymentAborted = false; + + /** + * Whether global listeners have been added. + */ + areListenersInitialized = false; + + /** + * The cart data represented if the product were to be added to the cart (or, on cart/checkout pages, the cart data itself). + * This is useful on product pages to understand if shipping is needed. + */ + cachedCartData = undefined; + + /** + * API to interface with the cart. + * + * @type {PaymentRequestCartApi} + */ + paymentRequestCartApi = undefined; + + /** + * WCPayAPI instance. + * + * @type {WCPayAPI} + */ + wcpayApi = undefined; + + /** + * On page load for product pages, we might get some data from the backend (which might get overwritten later). + */ + initialProductData = undefined; + + constructor( { wcpayApi, paymentRequestCartApi, productData } ) { + this.wcpayApi = wcpayApi; + this.paymentRequestCartApi = paymentRequestCartApi; + this.initialProductData = productData; + } + + /** + * Starts the payment request + */ + async startPaymentRequest() { + // reference to this class' instance, to be used inside callbacks to avoid `this` misunderstandings. + const _self = this; + // TODO: is this creating multiple handlers to events on different `paymentRequest` objects? + const paymentRequest = getPaymentRequest( { + stripe: this.wcpayApi.getStripe(), + cartData: this.cachedCartData, + productData: this.initialProductData, + } ); + + // Check the availability of the Payment Request API first. + const paymentPermissionResult = await paymentRequest.canMakePayment(); + if ( ! paymentPermissionResult ) { + doAction( 'wcpay.payment-request.availability', { + paymentRequestType: null, + } ); + return; + } + + const buttonBranding = paymentPermissionResult.applePay + ? 'apple_pay' + : 'google_pay'; + + doAction( 'wcpay.payment-request.availability', { + paymentRequestType: buttonBranding, + } ); + + setPaymentRequestBranding( buttonBranding ); + trackPaymentRequestButtonLoad( + getPaymentRequestData( 'button_context' ) + ); + + // On PDP pages, we need to use an anonymous cart to check out. + // On cart, checkout, place order pages we instead use the cart itself. + if ( getPaymentRequestData( 'button_context' ) === 'product' ) { + await this.paymentRequestCartApi.createAnonymousCart(); + } + + const paymentRequestButton = this.wcpayApi + .getStripe() + .elements() + .create( 'paymentRequestButton', { + paymentRequest: paymentRequest, + style: { + paymentRequestButton: { + type: getPaymentRequestData( 'button' ).type, + theme: getPaymentRequestData( 'button' ).theme, + height: getPaymentRequestData( 'button' ).height + 'px', + }, + }, + } ); + paymentRequestButtonUi.showButton( paymentRequestButton ); + + this.attachPaymentRequestButtonEventListeners(); + removeAction( + 'wcpay.payment-request.update-button-data', + 'automattic/wcpay/payment-request' + ); + addAction( + 'wcpay.payment-request.update-button-data', + 'automattic/wcpay/payment-request', + async () => { + const newCartData = await _self.getCartData(); + // checking if items needed shipping, before assigning new cart data. + const didItemsNeedShipping = + _self.initialProductData?.needs_shipping || + _self.cachedCartData?.needs_shipping; + + _self.cachedCartData = newCartData; + + /** + * If the customer aborted the payment request, we need to re init the payment request button to ensure the shipping + * options are re-fetched. If the customer didn't abort the payment request, and the product's shipping status is + * consistent, we can simply update the payment request button with the new total and display items. + */ + if ( + ! _self.isPaymentAborted && + didItemsNeedShipping === newCartData.needs_shipping + ) { + paymentRequest.update( { + total: { + label: getPaymentRequestData( 'total_label' ), + amount: parseInt( + newCartData.totals.total_price, + 10 + ), + }, + displayItems: transformCartDataForDisplayItems( + newCartData + ), + } ); + } else { + _self.init().then( noop ); + } + } + ); + + const $addToCartButton = jQuery( '.single_add_to_cart_button' ); + + paymentRequestButton.on( 'click', ( event ) => { + trackPaymentRequestButtonClick( 'product' ); + + // If login is required for checkout, display redirect confirmation dialog. + if ( getPaymentRequestData( 'login_confirmation' ) ) { + event.preventDefault(); + displayLoginConfirmationDialog( buttonBranding ); + return; + } + + // First check if product can be added to cart. + if ( $addToCartButton.is( '.disabled' ) ) { + event.preventDefault(); // Prevent showing payment request modal. + 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; + } + + _self.paymentRequestCartApi.addProductToCart(); + } ); + + paymentRequest.on( 'cancel', () => { + _self.isPaymentAborted = true; + // clearing the cart to avoid issues with products with low or limited availability + // being held hostage by customers cancelling the PRB. + _self.paymentRequestCartApi.emptyCart(); + } ); + + paymentRequest.on( 'shippingaddresschange', async ( event ) => { + try { + // Please note that the `event.shippingAddress` might not contain all the fields. + // Some fields might not be present (like `line_1` or `line_2`) due to semi-anonymized data. + const cartData = await _self.paymentRequestCartApi.updateCustomer( + transformStripeShippingAddressForStoreApi( + event.shippingAddress + ) + ); + + event.updateWith( { + // Possible statuses: https://docs.stripe.com/js/appendix/payment_response#payment_response_object-complete + status: 'success', + shippingOptions: transformCartDataForShippingOptions( + cartData + ), + total: { + label: getPaymentRequestData( 'total_label' ), + amount: parseInt( cartData.totals.total_price, 10 ), + }, + displayItems: transformCartDataForDisplayItems( cartData ), + } ); + + _self.cachedCartData = cartData; + } catch ( error ) { + // Possible statuses: https://docs.stripe.com/js/appendix/payment_response#payment_response_object-complete + event.updateWith( { + status: 'fail', + } ); + } + } ); + + paymentRequest.on( 'shippingoptionchange', async ( event ) => { + try { + const cartData = await _self.paymentRequestCartApi.selectShippingRate( + { package_id: 0, rate_id: event.shippingOption.id } + ); + + event.updateWith( { + status: 'success', + total: { + label: getPaymentRequestData( 'total_label' ), + amount: parseInt( cartData.totals.total_price, 10 ), + }, + displayItems: transformCartDataForDisplayItems( cartData ), + } ); + _self.cachedCartData = cartData; + } catch ( error ) { + event.updateWith( { status: 'fail' } ); + } + } ); + + paymentRequest.on( 'paymentmethod', async ( event ) => { + // TODO: this works for PDPs - need to handle checkout scenarios for pay-for-order, cart, checkout. + try { + const response = await _self.paymentRequestCartApi.placeOrder( { + // adding extension data as a separate action, + // so that we make it harder for external plugins to modify or intercept checkout data. + ...transformStripePaymentMethodForStoreApi( event ), + extensions: applyFilters( + 'wcpay.payment-request.cart-place-order-extension-data', + {} + ), + } ); + + const confirmationRequest = _self.wcpayApi.confirmIntent( + response.payment_result.redirect_url + ); + // We need to call `complete` before redirecting to close the dialog for 3DS. + event.complete( 'success' ); + + let redirectUrl = ''; + + // `true` means there is no intent to confirm. + if ( confirmationRequest === true ) { + redirectUrl = response.payment_result.redirect_url; + } else { + redirectUrl = await confirmationRequest; + } + + jQuery.blockUI( { + message: null, + overlayCSS: { + background: '#fff', + opacity: 0.6, + }, + } ); + + window.location = redirectUrl; + } catch ( error ) { + event.complete( 'fail' ); + + jQuery( '.woocommerce-error' ).remove(); + + const $container = jQuery( + '.woocommerce-notices-wrapper' + ).first(); + + if ( $container.length ) { + $container.append( + jQuery( '
' ).text( + error.message + ) + ); + + jQuery( 'html, body' ).animate( + { + scrollTop: $container + .find( '.woocommerce-error' ) + .offset().top, + }, + 600 + ); + } + } + } ); + } + + attachPaymentRequestButtonEventListeners() { + if ( this.areListenersInitialized ) { + return; + } + + this.areListenersInitialized = true; + // Block the payment request button as soon as an "input" event is fired, to avoid sync issues + // when the customer clicks on the button before the debounced event is processed. + const $quantityInput = jQuery( '.quantity' ); + const handleQuantityChange = () => { + paymentRequestButtonUi.blockButton(); + }; + $quantityInput.on( 'input', '.qty', handleQuantityChange ); + $quantityInput.on( + 'input', + '.qty', + debounce( 250, async () => { + doAction( 'wcpay.payment-request.update-button-data' ); + await waitForAction( + 'wcpay.payment-request.update-button-data' + ); + paymentRequestButtonUi.unblockButton(); + } ) + ); + } + + async getCartData() { + if ( getPaymentRequestData( 'button_context' ) !== 'product' ) { + return await this.paymentRequestCartApi.getCart(); + } + + // creating a new cart and clearing it afterwards, + // to avoid scenarios where the stock for a product with limited (or low) availability is added to the cart, + // preventing other customers from purchasing. + const temporaryCart = new PaymentRequestCartApi(); + await temporaryCart.createAnonymousCart(); + + const cartData = await temporaryCart.addProductToCart(); + + // no need to wait for the request to end, it can be done asynchronously. + // using `.finally( noop )` to avoid annoying IDE warnings. + temporaryCart.emptyCart().finally( noop ); + + return cartData; + } + + /** + * Initialize event handlers and UI state + */ + async init() { + if ( ! this.cachedCartData ) { + try { + this.cachedCartData = await this.getCartData(); + } catch ( e ) { + // if something fails here, we can likely fall back on the `initialProductData`. + } + } + + this.startPaymentRequest().then( noop ); + + // After initializing a new payment request, we need to reset the isPaymentAborted flag. + this.isPaymentAborted = false; + + // once cart data has been fetched, we can safely clear cached product data. + if ( this.cachedCartData ) { + this.initialProductData = undefined; + } + } +} diff --git a/client/tokenized-payment-request/test/cart-api.test.js b/client/tokenized-payment-request/test/cart-api.test.js new file mode 100644 index 00000000000..ab1500005f8 --- /dev/null +++ b/client/tokenized-payment-request/test/cart-api.test.js @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import PaymentRequestCartApi from '../cart-api'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +global.wcpayPaymentRequestParams = {}; +global.wcpayPaymentRequestParams.nonce = {}; +global.wcpayPaymentRequestParams.nonce.tokenized_cart_nonce = + 'global_tokenized_cart_nonce'; + +describe( 'PaymentRequestCartApi', () => { + afterEach( () => { + jest.resetAllMocks(); + } ); + + it( 'should allow to create an anonymous cart for a specific class instance, without affecting other instances', async () => { + const headers = new Headers(); + headers.append( + 'X-WooPayments-Express-Payment-Request-Nonce', + 'tokenized_cart_nonce' + ); + headers.append( 'Nonce', 'nonce-value' ); + headers.append( 'Cart-Token', 'cart-token-value' ); + apiFetch.mockResolvedValue( { + headers: headers, + } ); + + const api = new PaymentRequestCartApi(); + const anotherApi = new PaymentRequestCartApi(); + + await api.createAnonymousCart(); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'GET', + path: '/wc/store/v1/cart', + credentials: 'omit', + parse: false, + } ) + ); + + apiFetch.mockClear(); + apiFetch.mockResolvedValue( {} ); + + await api.updateCustomer( { + billing_address: { first_name: 'First' }, + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: '/wc/store/v1/cart/update-customer', + credentials: 'omit', + headers: expect.objectContaining( { + 'X-WooPayments-Express-Payment-Request': true, + 'X-WooPayments-Express-Payment-Request-Nonce': + 'tokenized_cart_nonce', + Nonce: 'nonce-value', + 'Cart-Token': 'cart-token-value', + } ), + data: expect.objectContaining( { + billing_address: { first_name: 'First' }, + } ), + } ) + ); + + apiFetch.mockClear(); + await anotherApi.updateCustomer( { + billing_address: { last_name: 'Last' }, + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: '/wc/store/v1/cart/update-customer', + credentials: 'omit', + // in this case, no additional headers should have been submitted. + headers: expect.objectContaining( { + 'X-WooPayments-Express-Payment-Request': true, + 'X-WooPayments-Express-Payment-Request-Nonce': + 'global_tokenized_cart_nonce', + } ), + data: expect.objectContaining( { + billing_address: { last_name: 'Last' }, + } ), + } ) + ); + } ); + + it( 'should call `/cart/update-customer` with the global headers if the cart is not anonymous', async () => { + const api = new PaymentRequestCartApi(); + + await api.updateCustomer( { + billing_address: { last_name: 'Last' }, + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: '/wc/store/v1/cart/update-customer', + credentials: 'omit', + // in this case, no additional headers should have been submitted. + headers: expect.objectContaining( { + 'X-WooPayments-Express-Payment-Request': true, + 'X-WooPayments-Express-Payment-Request-Nonce': + 'global_tokenized_cart_nonce', + } ), + data: expect.objectContaining( { + billing_address: { last_name: 'Last' }, + } ), + } ) + ); + } ); +} ); diff --git a/client/tokenized-payment-request/test/payment-request.test.js b/client/tokenized-payment-request/test/payment-request.test.js new file mode 100644 index 00000000000..dbad721f2be --- /dev/null +++ b/client/tokenized-payment-request/test/payment-request.test.js @@ -0,0 +1,146 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addAction, doAction, doingAction } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import PaymentRequestCartApi from '../cart-api'; +import WooPaymentsPaymentRequest from '../payment-request'; +import { trackPaymentRequestButtonLoad } from '../tracking'; +import { waitFor } from '@testing-library/react'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +jest.mock( '../tracking', () => ( { + setPaymentRequestBranding: () => null, + trackPaymentRequestButtonClick: () => null, + trackPaymentRequestButtonLoad: jest.fn(), +} ) ); + +jest.mock( '../button-ui', () => ( { + showButton: () => null, + blockButton: () => null, + unblockButton: () => null, +} ) ); +jest.mock( '../debounce', () => ( wait, func ) => + function () { + func.apply( this, arguments ); + } +); + +const jQueryMock = ( selector ) => { + if ( typeof selector === 'function' ) { + return selector( jQueryMock ); + } + + return { + on: ( event, callbackOrSelector, callback2 ) => + addAction( + `payment-request-test.jquery-event.${ selector }${ + typeof callbackOrSelector === 'string' + ? `.${ callbackOrSelector }` + : '' + }.${ event }`, + 'tests', + typeof callbackOrSelector === 'string' + ? callback2 + : callbackOrSelector + ), + val: () => null, + is: () => null, + remove: () => null, + }; +}; +jQueryMock.blockUI = () => null; + +const waitForAction = async ( hookName ) => + await waitFor( () => doingAction( hookName ) === false ); + +describe( 'WooPaymentsPaymentRequest', () => { + let wcpayApi; + + beforeEach( () => { + global.$ = jQueryMock; + global.jQuery = jQueryMock; + global.wcpayPaymentRequestParams = { + button_context: 'cart', + checkout: { + needs_payer_phone: true, + country_code: 'US', + currency_code: 'usd', + }, + total_label: 'wcpay.test (via WooCommerce)', + button: { type: 'buy', theme: 'dark', height: '48' }, + }; + wcpayApi = { + getStripe: () => ( { + paymentRequest: () => ( { + update: () => null, + canMakePayment: () => ( { googlePay: true } ), + on: ( event, callback ) => + addAction( + `payment-request-test.registered-action.${ event }`, + 'tests', + callback + ), + } ), + elements: () => ( { + create: () => ( { on: () => null } ), + } ), + } ), + }; + } ); + + afterEach( () => { + jest.resetAllMocks(); + } ); + + it( 'should initialize the Stripe payment request, fire initial tracking, and attach event listeners', async () => { + apiFetch.mockResolvedValue( { + needs_shipping: false, + totals: { + currency_code: 'USD', + total_price: '20', + total_tax: '0', + total_shipping: '5', + }, + items: [ { name: 'Shirt', quantity: 1, prices: { price: '15' } } ], + } ); + const paymentRequestAvailabilityCallback = jest.fn(); + addAction( + 'wcpay.payment-request.availability', + 'test', + paymentRequestAvailabilityCallback + ); + + const cartApi = new PaymentRequestCartApi(); + const paymentRequest = new WooPaymentsPaymentRequest( { + wcpayApi: wcpayApi, + paymentRequestCartApi: cartApi, + } ); + + expect( paymentRequestAvailabilityCallback ).not.toHaveBeenCalled(); + expect( trackPaymentRequestButtonLoad ).not.toHaveBeenCalled(); + + await paymentRequest.init(); + + expect( paymentRequestAvailabilityCallback ).toHaveBeenCalledTimes( 1 ); + expect( paymentRequestAvailabilityCallback ).toHaveBeenCalledWith( + expect.objectContaining( { paymentRequestType: 'google_pay' } ) + ); + expect( trackPaymentRequestButtonLoad ).toHaveBeenCalledWith( 'cart' ); + + doAction( 'wcpay.payment-request.update-button-data' ); + await waitForAction( 'wcpay.payment-request.update-button-data' ); + expect( paymentRequestAvailabilityCallback ).toHaveBeenCalledTimes( 1 ); + + // firing this should initialize the button again. + doAction( 'payment-request-test.registered-action.cancel' ); + + doAction( 'wcpay.payment-request.update-button-data' ); + await waitForAction( 'wcpay.payment-request.update-button-data' ); + expect( paymentRequestAvailabilityCallback ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/client/tokenized-payment-request/tracking.js b/client/tokenized-payment-request/tracking.js new file mode 100644 index 00000000000..403160a80fe --- /dev/null +++ b/client/tokenized-payment-request/tracking.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { debounce } from 'lodash'; +import { recordUserEvent } from 'tracks'; + +let paymentRequestBranding; + +// Track the payment request button click event. +export const trackPaymentRequestButtonClick = ( source ) => { + const paymentRequestTypeEvents = { + google_pay: 'gpay_button_click', + apple_pay: 'applepay_button_click', + }; + + const event = paymentRequestTypeEvents[ paymentRequestBranding ]; + if ( ! event ) return; + + recordUserEvent( event, { source } ); +}; + +// Track the payment request button load event. +export const trackPaymentRequestButtonLoad = debounce( ( source ) => { + const paymentRequestTypeEvents = { + google_pay: 'gpay_button_load', + apple_pay: 'applepay_button_load', + }; + + const event = paymentRequestTypeEvents[ paymentRequestBranding ]; + if ( ! event ) return; + + recordUserEvent( event, { source } ); +}, 1000 ); + +export const setPaymentRequestBranding = ( branding ) => + ( paymentRequestBranding = branding ); diff --git a/client/tokenized-payment-request/transformers/stripe-to-wc.js b/client/tokenized-payment-request/transformers/stripe-to-wc.js new file mode 100644 index 00000000000..b30c935b46d --- /dev/null +++ b/client/tokenized-payment-request/transformers/stripe-to-wc.js @@ -0,0 +1,92 @@ +/** + * Transform 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 transformStripeShippingAddressForStoreApi = ( + shippingAddress +) => { + return { + shipping_address: { + first_name: + shippingAddress.recipient + ?.split( ' ' ) + ?.slice( 0, 1 ) + ?.join( ' ' ) ?? '', + last_name: + shippingAddress.recipient + ?.split( ' ' ) + ?.slice( 1 ) + ?.join( ' ' ) ?? '', + company: shippingAddress.organization ?? '', + address_1: shippingAddress.addressLine?.[ 0 ] ?? '', + address_2: shippingAddress.addressLine?.[ 1 ] ?? '', + city: shippingAddress.city ?? '', + state: shippingAddress.region ?? '', + country: shippingAddress.country ?? '', + postcode: shippingAddress.postalCode?.replace( ' ', '' ) ?? '', + }, + }; +}; + +/** + * Transform order data from Stripe's object to the expected format for WC. + * + * @param {Object} paymentData Stripe's order object. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const transformStripePaymentMethodForStoreApi = ( paymentData ) => { + const name = + ( paymentData.paymentMethod?.billing_details?.name ?? + paymentData.payerName ) || + ''; + const billing = paymentData.paymentMethod?.billing_details?.address ?? {}; + const shipping = paymentData.shippingAddress ?? {}; + + const paymentRequestType = + paymentData.walletName === 'applePay' ? 'apple_pay' : 'google_pay'; + + return { + customer_note: paymentData.order_comments, + billing_address: { + first_name: name.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + last_name: name.split( ' ' )?.slice( 1 )?.join( ' ' ) || '-', + company: billing.organization ?? '', + address_1: billing.line1 ?? '', + address_2: billing.line2 ?? '', + city: billing.city ?? '', + state: billing.state ?? '', + postcode: billing.postal_code ?? '', + country: billing.country ?? '', + email: + paymentData.paymentMethod?.billing_details?.email ?? + paymentData.payerEmail ?? + '', + phone: + paymentData.paymentMethod?.billing_details?.phone ?? + paymentData.payerPhone?.replace( '/[() -]/g', '' ) ?? + '', + }, + // refreshing any shipping address data, now that the customer is placing the order. + ...transformStripeShippingAddressForStoreApi( shipping ), + payment_method: 'woocommerce_payments', + payment_data: [ + { + key: 'payment_request_type', + value: paymentRequestType, + }, + { + key: 'wcpay-fraud-prevention-token', + value: window.wcpayFraudPreventionToken ?? '', + }, + { + key: 'wcpay-payment-method', + value: paymentData.paymentMethod?.id, + }, + ], + }; +}; diff --git a/client/tokenized-payment-request/transformers/wc-to-stripe.js b/client/tokenized-payment-request/transformers/wc-to-stripe.js new file mode 100644 index 00000000000..1d04026e7f5 --- /dev/null +++ b/client/tokenized-payment-request/transformers/wc-to-stripe.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Transforms the data from the Store API Cart response to `displayItems` for the Stripe PRB. + * See https://docs.stripe.com/js/appendix/payment_item_object for the data structure + * + * @param {Object} cartData Store API Cart response object. + * @return {{pending: boolean, label: string, amount: integer}} `displayItems` for Stripe. + */ +export const transformCartDataForDisplayItems = ( cartData ) => { + const displayItems = cartData.items.map( ( item ) => ( { + amount: parseInt( item.prices.price, 10 ), + // TODO: should we also add variation attributes? + label: [ item.name, item.quantity > 1 && ` (x${ item.quantity })` ] + .filter( Boolean ) + .join( '' ), + pending: true, + } ) ); + + if ( cartData.totals.total_tax ) { + displayItems.push( { + amount: parseInt( cartData.totals.total_tax, 10 ), + label: __( 'Tax', 'woocommerce-payments' ), + pending: true, + } ); + } + + if ( cartData.totals.total_shipping ) { + displayItems.push( { + amount: parseInt( cartData.totals.total_shipping, 10 ), + label: __( 'Shipping', 'woocommerce-payments' ), + pending: true, + } ); + } + + return displayItems; +}; + +/** + * Transforms the data from the Store API Cart response to `shippingOptions` for the Stripe PRB. + * + * @param {Object} cartData Store API Cart response object. + * @return {{id: string, label: string, amount: integer, detail: string}} `shippingOptions` for Stripe. + */ +export const transformCartDataForShippingOptions = ( cartData ) => + cartData.shipping_rates[ 0 ].shipping_rates.map( ( rate ) => ( { + id: rate.rate_id, + label: rate.name, + amount: parseInt( rate.price, 10 ), + detail: '', + } ) ); diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 59f37816ee0..e8b028592d0 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -23,6 +23,7 @@ class WC_Payments_Features { const AUTH_AND_CAPTURE_FLAG_NAME = '_wcpay_feature_auth_and_capture'; const DISPUTE_ISSUER_EVIDENCE = '_wcpay_feature_dispute_issuer_evidence'; const STREAMLINE_REFUNDS_FLAG_NAME = '_wcpay_feature_streamline_refunds'; + const TOKENIZED_CART_PRB_FLAG_NAME = '_wcpay_feature_tokenized_cart_prb'; const PAYMENT_OVERVIEW_WIDGET_FLAG_NAME = '_wcpay_feature_payment_overview_widget'; /** @@ -36,6 +37,15 @@ public static function are_payments_enabled() { return is_array( $account ) && ( $account['payments_enabled'] ?? false ); } + /** + * Checks whether the "tokenized cart" feature for PRBs is enabled. + * + * @return bool + */ + public static function is_tokenized_cart_prb_enabled(): bool { + return '1' === get_option( self::TOKENIZED_CART_PRB_FLAG_NAME, '0' ); + } + /** * Checks whether streamline refunds is enabled. * diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index 45d981efc68..47a49d5409e 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -104,6 +104,126 @@ public function init() { // will be used to calculate it whenever the option value is retrieved instead. // It's used for displaying inbox notifications. add_filter( 'pre_option_wcpay_is_apple_pay_enabled', [ $this, 'get_option_is_apple_pay_enabled' ], 10, 1 ); + + if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() ) { + add_filter( 'rest_pre_dispatch', [ $this, 'tokenized_cart_store_api_address_normalization' ], 10, 3 ); + add_filter( + 'rest_post_dispatch', + [ $this, 'tokenized_cart_store_api_nonce_headers' ], + 10, + 3 + ); + } + } + + /** + * Google Pay/Apple Pay parameters for address data might need some massaging for some of the countries. + * Ensuring that the Store API doesn't throw a `rest_invalid_param` error message for some of those scenarios. + * + * @param mixed $response Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed + */ + public function tokenized_cart_store_api_address_normalization( $response, $server, $request ) { + if ( 'true' !== $request->get_header( 'X-WooPayments-Express-Payment-Request' ) ) { + return $response; + } + + // header added as additional layer of security. + $nonce = $request->get_header( 'X-WooPayments-Express-Payment-Request-Nonce' ); + if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_nonce' ) ) { + return $response; + } + + // This route is used to get shipping rates. + // GooglePay/ApplePay might provide us with "trimmed" zip codes. + // If that's the case, let's temporarily allow to skip the zip code validation, in order to get some shipping rates. + if ( $request->get_route() === '/wc/store/v1/cart/update-customer' ) { + add_filter( 'woocommerce_validate_postcode', [ $this, 'maybe_skip_postcode_validation' ], 10, 3 ); + } + + $request_data = $request->get_json_params(); + if ( isset( $request_data['shipping_address'] ) ) { + $request->set_param( 'shipping_address', $this->transform_prb_address_data( $request_data['shipping_address'] ) ); + } + if ( isset( $request_data['billing_address'] ) ) { + $request->set_param( 'billing_address', $this->transform_prb_address_data( $request_data['billing_address'] ) ); + } + + return $response; + } + + /** + * In order to create an additional layer of security, we're adding a custom nonce to the Store API REST responses. + * This nonce is added as a response header on the Store API, because nonces are tied to user sessions, + * and anonymous carts count as separate user sessions. + * + * @param \WP_HTTP_Response $response Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return \WP_HTTP_Response + */ + public function tokenized_cart_store_api_nonce_headers( $response, $server, $request ) { + if ( $request->get_route() === '/wc/store/v1/cart' ) { + $response->header( 'X-WooPayments-Express-Payment-Request-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + } + + return $response; + } + + /** + * Allows certain "redacted" postcodes for some countries to bypass WC core validation. + * + * @param bool $valid Whether the postcode is valid. + * @param string $postcode The postcode in question. + * @param string $country The country for the postcode. + * + * @return bool + */ + public function maybe_skip_postcode_validation( $valid, $postcode, $country ) { + if ( ! in_array( $country, [ Country_Code::UNITED_KINGDOM, Country_Code::CANADA ], true ) ) { + return $valid; + } + + // We padded the string with `0` in the `get_normalized_postal_code` method. + // It's a flimsy check, but better than nothing. + // Plus, this check is only made for the scenarios outlined in the `tokenized_cart_store_api_address_normalization` method. + if ( substr( $postcode, -1 ) === '0' ) { + return true; + } + + return $valid; + } + + /** + * Transform a GooglePay/ApplePay address data fields into values that are valid for WooCommerce. + * + * @param array $address The address to normalize from the GooglePay/ApplePay request. + * + * @return array + */ + private function transform_prb_address_data( $address ) { + $country = $address['country'] ?? ''; + if ( empty( $country ) ) { + return $address; + } + + // States from Apple Pay or Google Pay are in long format, we need their short format.. + $state = $address['state'] ?? ''; + if ( ! empty( $state ) ) { + $address['state'] = $this->get_normalized_state( $state, $country ); + } + + // Normalizes postal code in case of redacted data from Apple Pay or Google Pay. + $postcode = $address['postcode'] ?? ''; + if ( ! empty( $postcode ) ) { + $address['postcode'] = $this->get_normalized_postal_code( $postcode, $country ); + } + + return $address; } /** @@ -486,12 +606,12 @@ public function get_normalized_postal_code( $postcode, $country ) { * the postal code and not calculate shipping zones correctly. */ if ( Country_Code::UNITED_KINGDOM === $country ) { - // Replaces a redacted string with something like LN10***. - return str_pad( preg_replace( '/\s+/', '', $postcode ), 7, '*' ); + // Replaces a redacted string with something like N1C0000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 7, '0' ); } - if ( 'CA' === $country ) { - // Replaces a redacted string with something like L4Y***. - return str_pad( preg_replace( '/\s+/', '', $postcode ), 6, '*' ); + if ( Country_Code::CANADA === $country ) { + // Replaces a redacted string with something like H3B000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 6, '0' ); } return $postcode; @@ -500,7 +620,7 @@ public function get_normalized_postal_code( $postcode, $country ) { /** * Add needed order meta * - * @param integer $order_id The order ID. + * @param integer $order_id The order ID. * * @return void */ @@ -535,7 +655,7 @@ public function add_order_meta( $order_id ) { */ public function should_show_payment_request_button() { // If account is not connected, then bail. - if ( ! $this->account->is_stripe_connected( false ) ) { + if ( ! $this->account->is_stripe_connected() ) { return false; } @@ -567,14 +687,16 @@ public function should_show_payment_request_button() { } // Product page, but has unsupported product type. - if ( $this->express_checkout_helper->is_product() && ! $this->is_product_supported() ) { + if ( $this->express_checkout_helper->is_product() && ! apply_filters( 'wcpay_payment_request_is_product_supported', $this->is_product_supported(), $this->express_checkout_helper->get_product() ) ) { Logger::log( 'Product page has unsupported product type ( Payment Request button disabled )' ); + return false; } // Cart has unsupported product type. if ( ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) && ! $this->has_allowed_items_in_cart() ) { Logger::log( 'Items in the cart have unsupported product type ( Payment Request button disabled )' ); + return false; } @@ -591,6 +713,7 @@ public function should_show_payment_request_button() { ) { Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); + return false; } @@ -641,10 +764,10 @@ public function has_allowed_items_in_cart() { /** * Filter whether product supports Payment Request Button on cart page. * - * @since 6.9.0 - * * @param boolean $is_supported Whether product supports Payment Request Button on cart page. - * @param object $_product Product object. + * @param object $_product Product object. + * + * @since 6.9.0 */ if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { return false; @@ -680,7 +803,9 @@ public function has_subscription_product() { if ( WC_Subscriptions_Product::is_subscription( $product ) ) { return true; } - } elseif ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) { + } + + if ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) { if ( WC_Subscriptions_Cart::cart_contains_subscription() ) { return true; } @@ -693,6 +818,7 @@ public function has_subscription_product() { * Returns the login redirect URL. * * @param string $redirect Default redirect URL. + * * @return string Redirect URL. */ public function get_login_redirect_url( $redirect ) { @@ -733,6 +859,7 @@ public function scripts() { 'get_selected_product_data' => wp_create_nonce( 'wcpay-get-selected-product-data' ), 'platform_tracker' => wp_create_nonce( 'platform_tracks_nonce' ), 'pay_for_order' => wp_create_nonce( 'pay_for_order' ), + 'tokenized_cart_nonce' => wp_create_nonce( 'woopayments_tokenized_cart_nonce' ), ], 'checkout' => [ 'currency_code' => strtolower( get_woocommerce_currency() ), @@ -743,23 +870,46 @@ public function scripts() { ], 'button' => $this->get_button_settings(), 'login_confirmation' => $this->get_login_confirmation_settings(), - 'is_product_page' => $this->express_checkout_helper->is_product(), - 'button_context' => $this->express_checkout_helper->get_button_context(), - 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), 'product' => $this->get_product_data(), 'total_label' => $this->express_checkout_helper->get_total_label(), + 'button_context' => $this->express_checkout_helper->get_button_context(), + 'is_product_page' => $this->express_checkout_helper->is_product(), + 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), ]; - WC_Payments::register_script_with_dependencies( 'WCPAY_PAYMENT_REQUEST', 'dist/payment-request', [ 'jquery', 'stripe' ] ); - - WC_Payments_Utils::enqueue_style( - 'WCPAY_PAYMENT_REQUEST', - plugins_url( 'dist/payment-request.css', WCPAY_PLUGIN_FILE ), - [], - WC_Payments::get_file_version( 'dist/payment-request.css' ) - ); + if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() && $this->express_checkout_helper->is_product() ) { + WC_Payments::register_script_with_dependencies( + 'WCPAY_PAYMENT_REQUEST', + 'dist/tokenized-payment-request', + [ + 'jquery', + 'stripe', + ] + ); + WC_Payments_Utils::enqueue_style( + 'WCPAY_PAYMENT_REQUEST', + plugins_url( 'dist/tokenized-payment-request.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/tokenized-payment-request.css' ) + ); + } else { + WC_Payments::register_script_with_dependencies( + 'WCPAY_PAYMENT_REQUEST', + 'dist/payment-request', + [ + 'jquery', + 'stripe', + ] + ); + WC_Payments_Utils::enqueue_style( + 'WCPAY_PAYMENT_REQUEST', + plugins_url( 'dist/payment-request.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/payment-request.css' ) + ); + } wp_localize_script( 'WCPAY_PAYMENT_REQUEST', 'wcpayPaymentRequestParams', $payment_request_params ); @@ -795,30 +945,50 @@ public function display_payment_request_button_html() { * @return boolean */ private function is_product_supported() { - $product = $this->express_checkout_helper->get_product(); - $is_supported = true; + $product = $this->express_checkout_helper->get_product(); + if ( is_null( $product ) ) { + return false; + } - if ( is_null( $product ) - || ! is_object( $product ) - || ! in_array( $product->get_type(), $this->supported_product_types(), true ) - || ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) // Trial subscriptions with shipping are not supported. - || ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) // Pre Orders charge upon release not supported. - || ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) // Composite products are not supported on the product page. - || ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) // Mix and match products are not supported on the product page. - ) { - $is_supported = false; - } elseif ( class_exists( 'WC_Product_Addons_Helper' ) ) { + if ( ! is_object( $product ) ) { + return false; + } + + if ( ! in_array( $product->get_type(), $this->supported_product_types(), true ) ) { + return false; + } + + // Trial subscriptions with shipping are not supported. + if ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) { + return false; + } + + // Pre Orders charge upon release not supported. + if ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) { + return false; + } + + // Composite products are not supported on the product page. + if ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) { + return false; + } + + // Mix and match products are not supported on the product page. + if ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) { + return false; + } + + if ( class_exists( 'WC_Product_Addons_Helper' ) ) { // File upload addon not supported. $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); foreach ( $product_addons as $addon ) { if ( 'file_upload' === $addon['type'] ) { - $is_supported = false; - break; + return false; } } } - return apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); + return true; } /** @@ -884,7 +1054,7 @@ public function ajax_get_shipping_options() { /** * Gets shipping options available for specified shipping address * - * @param array $shipping_address Shipping address. + * @param array $shipping_address Shipping address. * @param boolean $itemized_display_items Indicates whether to show subtotals or itemized views. * * @return array Shipping options data. @@ -1123,6 +1293,7 @@ public function ajax_pay_for_order() { 'messages' => __( 'Invalid request', 'woocommerce-payments' ), ]; wp_send_json( $response, 400 ); + return; } @@ -1216,7 +1387,7 @@ public function sanitize_string( $string ) { /** * Get normalized state from Payment Request API dropdown list of states. * - * @param string $state Full state name or state code. + * @param string $state Full state name or state code. * @param string $country Two-letter country code. * * @return string Normalized state or original state input value. @@ -1248,7 +1419,7 @@ public function get_normalized_state_from_pr_states( $state, $country ) { /** * Get normalized state from WooCommerce list of translated states. * - * @param string $state Full state name or state code. + * @param string $state Full state name or state code. * @param string $country Two-letter country code. * * @return string Normalized state or original state input value. @@ -1273,7 +1444,7 @@ public function get_normalized_state_from_wc_states( $state, $country ) { * what WC is expecting and throws an error. An example * for Ireland, the county dropdown in Chrome shows "Co. Clare" format. * - * @param string $state Full state name or an already normalized abbreviation. + * @param string $state Full state name or an already normalized abbreviation. * @param string $country Two-letter country code. * * @return string Normalized state abbreviation. @@ -1481,7 +1652,8 @@ public function get_login_confirmation_settings() { $redirect_url = add_query_arg( [ '_wpnonce' => wp_create_nonce( 'wcpay-set-redirect-url' ), - 'wcpay_payment_request_redirect_url' => rawurlencode( home_url( add_query_arg( [] ) ) ), // Current URL to redirect to after login. + 'wcpay_payment_request_redirect_url' => rawurlencode( home_url( add_query_arg( [] ) ) ), + // Current URL to redirect to after login. ], home_url() ); @@ -1496,7 +1668,8 @@ public function get_login_confirmation_settings() { * Calculates taxes as displayed on cart, based on a product and a particular price. * * @param WC_Product $product The product, for retrieval of tax classes. - * @param float $price The price, which to calculate taxes for. + * @param float $price The price, which to calculate taxes for. + * * @return array An array of final taxes. */ private function get_taxes_like_cart( $product, $price ) { 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 acc9ffd3c44..e1affafd506 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 @@ -118,6 +118,7 @@ public function display_express_checkout_buttons() { } $this->payment_request_button_handler->display_payment_request_button_html(); ?> +
display_express_checkout_separator_if_necessary( $separator_starts_hidden ); diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php index 2aff7815095..75495a3b990 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php @@ -408,7 +408,9 @@ public function get_product() { if ( is_product() ) { return wc_get_product( $post->ID ); - } elseif ( wc_post_content_has_shortcode( 'product_page' ) ) { + } + + if ( wc_post_content_has_shortcode( 'product_page' ) ) { // Get id from product_page shortcode. preg_match( '/\[product_page id="(?\d+)"\]/', $post->post_content, $shortcode_match ); if ( isset( $shortcode_match['id'] ) ) { diff --git a/includes/multi-currency/Compatibility.php b/includes/multi-currency/Compatibility.php index c88fc94cdcd..4f60915fb97 100644 --- a/includes/multi-currency/Compatibility.php +++ b/includes/multi-currency/Compatibility.php @@ -78,7 +78,7 @@ public function get_compatibility_classes(): array { } /** - * Checks to see if the if the selected currency needs to be overridden. + * Checks to see if the selected currency needs to be overridden. * * @return mixed Three letter currency code or false if not. */ diff --git a/includes/woopay-user/class-woopay-save-user.php b/includes/woopay-user/class-woopay-save-user.php index 78096190fba..17fdcaa7cc8 100644 --- a/includes/woopay-user/class-woopay-save-user.php +++ b/includes/woopay-user/class-woopay-save-user.php @@ -36,6 +36,10 @@ public function __construct() { * Load scripts and styles for checkout page. */ public function register_checkout_page_scripts() { + if ( ! is_checkout() && ! has_block( 'woocommerce/checkout' ) ) { + return; + } + // Don't enqueue checkout page scripts when WCPay isn't available. $gateways = WC()->payment_gateways->get_available_payment_gateways(); if ( ! isset( $gateways['woocommerce_payments'] ) ) { diff --git a/includes/woopay/class-woopay-utilities.php b/includes/woopay/class-woopay-utilities.php index 59ec36d3772..588285246cd 100644 --- a/includes/woopay/class-woopay-utilities.php +++ b/includes/woopay/class-woopay-utilities.php @@ -121,7 +121,7 @@ public function get_woopay_request_signature() { public function should_save_platform_customer() { $session_data = []; - if ( isset( WC()->session ) && WC()->session->has_session() ) { + if ( isset( WC()->session ) && method_exists( WC()->session, 'has_session' ) && WC()->session->has_session() ) { $session_data = WC()->session->get( WooPay_Extension::WOOPAY_SESSION_KEY ); } 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 90f8f68c7aa..2c9db0147a7 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 @@ -274,6 +274,104 @@ private static function get_shipping_option_rate_id( $instance_id ) { return $method->get_rate_id(); } + public function test_tokenized_cart_address_avoid_normalization_when_missing_header() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Express-Payment-Request', null ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + + $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + + $this->assertSame( 'California', $shipping_address['state'] ); + } + + public function test_tokenized_cart_address_avoid_normalization_when_wrong_nonce() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Express-Payment-Request', 'true' ); + $request->set_header( 'X-WooPayments-Express-Payment-Request-Nonce', 'invalid-nonce' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + + $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + + $this->assertSame( 'California', $shipping_address['state'] ); + } + + public function test_tokenized_cart_address_state_normalization() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Express-Payment-Request', 'true' ); + $request->set_header( 'X-WooPayments-Express-Payment-Request-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + $request->set_param( + 'billing_address', + [ + 'country' => 'CA', + 'state' => 'Colombie-Britannique', + ] + ); + + $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + $billing_address = $request->get_param( 'billing_address' ); + + $this->assertSame( 'CA', $shipping_address['state'] ); + $this->assertSame( 'BC', $billing_address['state'] ); + } + + public function test_tokenized_cart_address_postcode_normalization() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Express-Payment-Request', 'true' ); + $request->set_header( 'X-WooPayments-Express-Payment-Request-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'CA', + 'postcode' => 'H3B', + ] + ); + $request->set_param( + 'billing_address', + [ + 'country' => 'US', + 'postcode' => '90210', + ] + ); + + $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + $billing_address = $request->get_param( 'billing_address' ); + + // this should be modified. + $this->assertSame( 'H3B000', $shipping_address['postcode'] ); + // this shouldn't be modified. + $this->assertSame( '90210', $billing_address['postcode'] ); + } public function test_get_shipping_options_returns_shipping_options() { $data = $this->pr->get_shipping_options( self::SHIPPING_ADDRESS ); diff --git a/webpack/shared.js b/webpack/shared.js index 63a71ca649a..5ff3c9e6792 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -21,6 +21,7 @@ module.exports = { cart: './client/cart/index.js', checkout: './client/checkout/classic/event-handlers.js', 'payment-request': './client/payment-request/index.js', + 'tokenized-payment-request': './client/tokenized-payment-request/index.js', 'subscription-edit-page': './client/subscription-edit-page.js', tos: './client/tos/index.js', 'payment-gateways': './client/payment-gateways/index.js',