diff --git a/changelog/refactor-tokenized-ece-base-implementation b/changelog/refactor-tokenized-ece-base-implementation new file mode 100644 index 00000000000..d642531bbcf --- /dev/null +++ b/changelog/refactor-tokenized-ece-base-implementation @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: feat: tokenized cart ECE base implementation + + diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index 8b35c61db89..f43720bb9d3 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -12,7 +12,8 @@ import { getExpressCheckoutButtonStyleSettings, getExpressCheckoutData, normalizeLineItems, -} from './utils/index'; + displayLoginConfirmation, +} from './utils'; import { onAbortPaymentHandler, onCancelHandler, @@ -23,7 +24,6 @@ import { shippingAddressChangeHandler, shippingRateChangeHandler, } from './event-handlers'; -import { displayLoginConfirmation } from './utils'; jQuery( ( $ ) => { // Don't load if blocks checkout is being loaded. diff --git a/client/express-checkout/utils/index.ts b/client/express-checkout/utils/index.ts index f6089857abc..7b56b702045 100644 --- a/client/express-checkout/utils/index.ts +++ b/client/express-checkout/utils/index.ts @@ -68,6 +68,9 @@ export interface WCPayExpressCheckoutParams { platform_tracker: string; shipping: string; update_shipping: string; + tokenized_cart_nonce: string; + tokenized_cart_session_nonce: string; + store_api_nonce: string; }; /** diff --git a/client/tokenized-express-checkout/__tests__/cart-api.test.js b/client/tokenized-express-checkout/__tests__/cart-api.test.js new file mode 100644 index 00000000000..b60bffd6299 --- /dev/null +++ b/client/tokenized-express-checkout/__tests__/cart-api.test.js @@ -0,0 +1,196 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import ExpressCheckoutCartApi from '../cart-api'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +global.wcpayExpressCheckoutParams = {}; +global.wcpayExpressCheckoutParams.nonce = {}; +global.wcpayExpressCheckoutParams.nonce.store_api_nonce = + 'global_store_api_nonce'; +global.wcpayExpressCheckoutParams.nonce.tokenized_cart_nonce = + 'global_tokenized_cart_nonce'; +global.wcpayExpressCheckoutParams.nonce.tokenized_cart_session_nonce = + 'global_tokenized_cart_session_nonce'; +global.wcpayExpressCheckoutParams.checkout = {}; +global.wcpayExpressCheckoutParams.checkout.currency_code = 'USD'; + +describe( 'ExpressCheckoutCartApi', () => { + afterEach( () => { + jest.resetAllMocks(); + } ); + + it( 'should allow to create an anonymous cart for a specific class instance, without affecting other instances', async () => { + global.wcpayExpressCheckoutParams.button_context = 'product'; + const headers = new Headers(); + headers.append( + 'X-WooPayments-Tokenized-Cart-Session', + 'tokenized_cart_session' + ); + headers.append( 'Nonce', 'nonce-value' ); + apiFetch.mockResolvedValue( { + headers: headers, + json: () => Promise.resolve( {} ), + } ); + + const api = new ExpressCheckoutCartApi(); + const anotherApi = new ExpressCheckoutCartApi(); + + api.useSeparateCart(); + await api.getCart(); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'GET', + path: expect.stringContaining( '/wc/store/v1/cart' ), + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart-Session': '', + 'X-WooPayments-Tokenized-Cart-Session-Nonce': + 'global_tokenized_cart_session_nonce', + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + Nonce: 'global_store_api_nonce', + } ), + } ) + ); + + apiFetch.mockClear(); + apiFetch.mockResolvedValue( { + headers: new Headers(), + json: () => Promise.resolve( {} ), + } ); + + await api.updateCustomer( { + billing_address: { first_name: 'First' }, + } ); + expect( apiFetch ).toHaveBeenLastCalledWith( + expect.objectContaining( { + method: 'POST', + path: expect.stringContaining( + '/wc/store/v1/cart/update-customer' + ), + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart': true, + 'X-WooPayments-Tokenized-Cart-Session-Nonce': + 'global_tokenized_cart_session_nonce', + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + 'X-WooPayments-Tokenized-Cart-Session': + 'tokenized_cart_session', + Nonce: 'nonce-value', + } ), + data: expect.objectContaining( { + billing_address: { first_name: 'First' }, + } ), + } ) + ); + + apiFetch.mockClear(); + apiFetch.mockResolvedValue( { + headers: new Headers(), + json: () => Promise.resolve( {} ), + } ); + await anotherApi.updateCustomer( { + billing_address: { last_name: 'Last' }, + } ); + expect( apiFetch ).toHaveBeenLastCalledWith( + expect.objectContaining( { + method: 'POST', + path: expect.stringContaining( + '/wc/store/v1/cart/update-customer' + ), + // in this case, no additional headers should have been submitted. + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart': true, + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + Nonce: 'global_store_api_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 () => { + global.wcpayExpressCheckoutParams.button_context = 'cart'; + apiFetch.mockResolvedValue( { + headers: new Headers(), + json: () => Promise.resolve( {} ), + } ); + const api = new ExpressCheckoutCartApi(); + + await api.updateCustomer( { + billing_address: { last_name: 'Last' }, + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: expect.stringContaining( + '/wc/store/v1/cart/update-customer' + ), + // in this case, no additional headers should have been submitted. + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart': true, + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + } ), + data: expect.objectContaining( { + billing_address: { last_name: 'Last' }, + } ), + } ) + ); + } ); + + it( 'should store received header information for subsequent usage', async () => { + global.wcpayExpressCheckoutParams.button_context = 'cart'; + const headers = new Headers(); + headers.append( 'Nonce', 'nonce-value' ); + apiFetch.mockResolvedValue( { + headers, + json: () => Promise.resolve( {} ), + } ); + const api = new ExpressCheckoutCartApi(); + + await api.getCart(); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'GET', + path: expect.stringContaining( '/wc/store/v1/cart' ), + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart-Session-Nonce': undefined, + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + } ), + } ) + ); + + await api.updateCustomer( { + billing_address: { last_name: 'Last' }, + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: expect.stringContaining( + '/wc/store/v1/cart/update-customer' + ), + // in this case, no additional headers should have been submitted. + headers: expect.objectContaining( { + 'X-WooPayments-Tokenized-Cart-Session-Nonce': undefined, + 'X-WooPayments-Tokenized-Cart': true, + 'X-WooPayments-Tokenized-Cart-Nonce': + 'global_tokenized_cart_nonce', + Nonce: 'nonce-value', + } ), + } ) + ); + } ); +} ); diff --git a/client/tokenized-express-checkout/__tests__/order-api.js b/client/tokenized-express-checkout/__tests__/order-api.js new file mode 100644 index 00000000000..8f95a0887b8 --- /dev/null +++ b/client/tokenized-express-checkout/__tests__/order-api.js @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import ExpressCheckoutOrderApi from '../order-api'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +global.wcpayExpressCheckoutParams = {}; +global.wcpayExpressCheckoutParams.nonce = {}; +global.wcpayExpressCheckoutParams.nonce.store_api_nonce = + 'global_store_api_nonce'; + +describe( 'ExpressCheckoutOrderApi', () => { + afterEach( () => { + jest.resetAllMocks(); + } ); + + it( 'gets order data with the provided arguments', async () => { + const api = new ExpressCheckoutOrderApi( { + orderId: '1', + key: 'key_123', + billingEmail: 'cheese@toast.com', + } ); + + await api.getCart(); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'GET', + path: expect.stringMatching( + // I am using a regex to ensure the order of the parameters doesn't matter. + /(?=.*\/wc\/store\/v1\/order\/1)(?=.*billing_email=cheese%40toast.com)(?=.*key=key_123)/ + ), + } ) + ); + } ); + + it( 'places an order', async () => { + const api = new ExpressCheckoutOrderApi( { + orderId: '1', + key: 'key_123', + billingEmail: 'cheese@toast.com', + } ); + + await api.placeOrder( { + billing_address: { + first_name: 'Fake', + }, + shipping_address: { + first_name: 'Test', + }, + anythingElse: 'passedThrough', + } ); + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: '/wc/store/v1/checkout/1', + headers: expect.objectContaining( { + Nonce: 'global_store_api_nonce', + } ), + data: expect.objectContaining( { + key: 'key_123', + billing_email: 'cheese@toast.com', + billing_address: undefined, + shipping_address: undefined, + anythingElse: 'passedThrough', + } ), + } ) + ); + } ); + + it( 'places an order with the previous API request data', async () => { + const api = new ExpressCheckoutOrderApi( { + orderId: '1', + key: 'key_123', + billingEmail: 'cheese@toast.com', + } ); + + apiFetch.mockResolvedValueOnce( { + billing_address: { + first_name: 'Fake', + last_name: 'Test', + }, + shipping_address: { + first_name: 'Test', + last_name: 'Fake', + }, + } ); + await api.getCart(); + + await api.placeOrder( { + billing_address: { + first_name: 'Fake', + }, + shipping_address: { + first_name: 'Test', + }, + anythingElse: 'passedThrough', + } ); + + expect( apiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + method: 'POST', + path: '/wc/store/v1/checkout/1', + headers: expect.objectContaining( { + Nonce: 'global_store_api_nonce', + } ), + data: expect.objectContaining( { + key: 'key_123', + billing_email: 'cheese@toast.com', + billing_address: expect.objectContaining( { + first_name: 'Fake', + last_name: 'Test', + } ), + shipping_address: expect.objectContaining( { + first_name: 'Test', + last_name: 'Fake', + } ), + anythingElse: 'passedThrough', + } ), + } ) + ); + } ); +} ); diff --git a/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js b/client/tokenized-express-checkout/blocks/checkPaymentMethodIsAvailable.js similarity index 100% rename from client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js rename to client/tokenized-express-checkout/blocks/checkPaymentMethodIsAvailable.js diff --git a/client/tokenized-express-checkout/blocks/index.js b/client/tokenized-express-checkout/blocks/index.js index d2cefd85eb7..ef5f747a074 100644 --- a/client/tokenized-express-checkout/blocks/index.js +++ b/client/tokenized-express-checkout/blocks/index.js @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; 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'; +import { checkPaymentMethodIsAvailable } from './checkPaymentMethodIsAvailable'; export const tokenizedExpressCheckoutElementApplePay = ( api ) => ( { paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, diff --git a/client/tokenized-payment-request/cart-api.js b/client/tokenized-express-checkout/cart-api.js similarity index 94% rename from client/tokenized-payment-request/cart-api.js rename to client/tokenized-express-checkout/cart-api.js index 52fcc25b46d..277cce18c09 100644 --- a/client/tokenized-payment-request/cart-api.js +++ b/client/tokenized-express-checkout/cart-api.js @@ -10,9 +10,9 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ -import { getPaymentRequestData } from './frontend-utils'; +import { getExpressCheckoutData } from './utils'; -export default class PaymentRequestCartApi { +export default class ExpressCheckoutCartApi { // 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) @@ -32,21 +32,21 @@ export default class PaymentRequestCartApi { path: addQueryArgs( options.path, { // `wcpayPaymentRequestParams` will always be defined if this file is needed. // If there's an issue with it, ask yourself why this file is queued and `wcpayPaymentRequestParams` isn't present. - currency: getPaymentRequestData( + currency: getExpressCheckoutData( 'checkout' ).currency_code.toUpperCase(), } ), headers: { // the Store API nonce, which could later be overwritten in subsequent requests. - Nonce: getPaymentRequestData( 'nonce' ).store_api_nonce, + Nonce: getExpressCheckoutData( 'nonce' ).store_api_nonce, // needed for validation of address data, etc. 'X-WooPayments-Tokenized-Cart-Nonce': - getPaymentRequestData( 'nonce' ).tokenized_cart_nonce || + getExpressCheckoutData( 'nonce' ).tokenized_cart_nonce || undefined, // necessary to validate any request made to the backend from the PDP. 'X-WooPayments-Tokenized-Cart-Session-Nonce': - getPaymentRequestData( 'button_context' ) === 'product' - ? getPaymentRequestData( 'nonce' ) + getExpressCheckoutData( 'button_context' ) === 'product' + ? getExpressCheckoutData( 'nonce' ) .tokenized_cart_session_nonce : undefined, ...this.cartRequestHeaders, @@ -170,7 +170,7 @@ export default class PaymentRequestCartApi { method: 'POST', path: '/wc/store/v1/cart/add-item', data: applyFilters( - 'wcpay.payment-request.cart-add-item', + 'wcpay.express-checkout.cart-add-item', productData ), } ); diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index 8b35c61db89..3b2fcb57df2 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -12,7 +12,8 @@ import { getExpressCheckoutButtonStyleSettings, getExpressCheckoutData, normalizeLineItems, -} from './utils/index'; + displayLoginConfirmation, +} from './utils'; import { onAbortPaymentHandler, onCancelHandler, @@ -23,7 +24,9 @@ import { shippingAddressChangeHandler, shippingRateChangeHandler, } from './event-handlers'; -import { displayLoginConfirmation } from './utils'; +import ExpressCheckoutCartApi from './cart-api'; +import ExpressCheckoutOrderApi from './order-api'; +import { getUPEConfig } from 'wcpay/utils/checkout'; jQuery( ( $ ) => { // Don't load if blocks checkout is being loaded. @@ -56,6 +59,15 @@ jQuery( ( $ ) => { } ); + let cartApi = new ExpressCheckoutCartApi(); + if ( getExpressCheckoutData( 'button_context' ) === 'pay_for_order' ) { + cartApi = new ExpressCheckoutOrderApi( { + orderId: getUPEConfig( 'order_id' ), + key: getUPEConfig( 'key' ), + billingEmail: getUPEConfig( 'billing_email' ), + } ); + } + let wcPayECEError = ''; const defaultErrorMessage = __( 'There was an error getting the product information.', diff --git a/client/tokenized-payment-request/order-api.js b/client/tokenized-express-checkout/order-api.js similarity index 91% rename from client/tokenized-payment-request/order-api.js rename to client/tokenized-express-checkout/order-api.js index 13c580e752c..96b1896cc59 100644 --- a/client/tokenized-payment-request/order-api.js +++ b/client/tokenized-express-checkout/order-api.js @@ -3,9 +3,13 @@ */ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import { getPaymentRequestData } from './frontend-utils'; -export default class PaymentRequestOrderApi { +/** + * Internal dependencies + */ +import { getExpressCheckoutData } from './utils'; + +export default class ExpressCheckoutOrderApi { // parameters used in every request, just in different ways. orderId; key; @@ -44,7 +48,7 @@ export default class PaymentRequestOrderApi { method: 'POST', path: `/wc/store/v1/checkout/${ this.orderId }`, headers: { - Nonce: getPaymentRequestData( 'nonce' ).store_api_nonce, + Nonce: getExpressCheckoutData( 'nonce' ).store_api_nonce, }, data: { ...paymentData, diff --git a/client/tokenized-express-checkout/test/event-handlers.js b/client/tokenized-express-checkout/test/event-handlers.js deleted file mode 100644 index 8c3d8a1c28b..00000000000 --- a/client/tokenized-express-checkout/test/event-handlers.js +++ /dev/null @@ -1,571 +0,0 @@ -/** - * 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/utils/test/index.ts b/client/tokenized-express-checkout/utils/__tests__/index.test.js similarity index 84% rename from client/tokenized-express-checkout/utils/test/index.ts rename to client/tokenized-express-checkout/utils/__tests__/index.test.js index e1e61edf988..651bef1d0dd 100644 --- a/client/tokenized-express-checkout/utils/test/index.ts +++ b/client/tokenized-express-checkout/utils/__tests__/index.test.js @@ -1,18 +1,14 @@ /** * Internal dependencies */ -import { - WCPayExpressCheckoutParams, - getErrorMessageFromNotice, - getExpressCheckoutData, -} from '../index'; +import { getErrorMessageFromNotice, getExpressCheckoutData } from '..'; 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 + 'does-not-exist' ) ).toBeNull(); } ); @@ -22,7 +18,7 @@ describe( 'Express checkout utils', () => { // the type assertion is fine. window.wcpayExpressCheckoutParams = { ajax_url: 'test', - } as WCPayExpressCheckoutParams; + }; expect( getExpressCheckoutData( 'ajax_url' ) ).toBe( 'test' ); } ); diff --git a/client/tokenized-express-checkout/utils/test/normalize.js b/client/tokenized-express-checkout/utils/__tests__/normalize.test.js similarity index 100% rename from client/tokenized-express-checkout/utils/test/normalize.js rename to client/tokenized-express-checkout/utils/__tests__/normalize.test.js diff --git a/client/tokenized-express-checkout/utils/index.ts b/client/tokenized-express-checkout/utils/index.ts index f6089857abc..9b92ec023ba 100644 --- a/client/tokenized-express-checkout/utils/index.ts +++ b/client/tokenized-express-checkout/utils/index.ts @@ -1,109 +1,10 @@ /** * Internal dependencies */ +import { WCPayExpressCheckoutParams } from 'wcpay/express-checkout/utils'; 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 >( @@ -111,7 +12,9 @@ export const getExpressCheckoutData = < ) => { if ( typeof window.wcpayExpressCheckoutParams !== 'undefined' ) { return window.wcpayExpressCheckoutParams[ key ] ?? null; - } else if ( typeof window.wc?.wcSettings !== 'undefined' ) { + } + + if ( typeof window.wc?.wcSettings !== 'undefined' ) { return window.wc.wcSettings.getSetting( 'ece_data' )?.[ key ] ?? null; } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php index 87c820f23f4..03c4feb32db 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php @@ -9,6 +9,7 @@ exit; } +use WCPay\Constants\Country_Code; use WCPay\Exceptions\Invalid_Price_Exception; use WCPay\Logger; @@ -44,6 +45,19 @@ public function init() { add_action( 'wc_ajax_wcpay_ece_get_cart_details', [ $this, 'ajax_get_cart_details' ] ); add_action( 'wc_ajax_wcpay_ece_update_shipping_method', [ $this, 'ajax_update_shipping_method' ] ); add_action( 'wc_ajax_wcpay_ece_get_selected_product_data', [ $this, 'ajax_get_selected_product_data' ] ); + + if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { + add_action( + 'woocommerce_store_api_checkout_update_order_from_request', + [ + $this, + 'tokenized_cart_set_payment_method_type', + ], + 10, + 2 + ); + add_filter( 'rest_pre_dispatch', [ $this, 'tokenized_cart_store_api_address_normalization' ], 10, 3 ); + } } /** @@ -421,4 +435,138 @@ public function ajax_add_to_cart() { wp_send_json( $data ); } + + /** + * Updates the checkout order based on the request, to set the Apple Pay/Google Pay payment method title. + * + * @param \WC_Order $order The order to be updated. + * @param \WP_REST_Request $request Store API request to update the order. + */ + public function tokenized_cart_set_payment_method_type( \WC_Order $order, \WP_REST_Request $request ) { + if ( ! isset( $request['payment_method'] ) || 'woocommerce_payments' !== $request['payment_method'] ) { + return; + } + + if ( empty( $request['payment_data'] ) ) { + return; + } + + $payment_data = []; + foreach ( $request['payment_data'] as $data ) { + $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); + } + + if ( empty( $payment_data['payment_request_type'] ) ) { + return; + } + + $payment_request_type = wc_clean( wp_unslash( $payment_data['payment_request_type'] ) ); + + $payment_method_titles = [ + 'apple_pay' => 'Apple Pay', + 'google_pay' => 'Google Pay', + ]; + + $suffix = apply_filters( 'wcpay_payment_request_payment_method_title_suffix', 'WooPayments' ); + if ( ! empty( $suffix ) ) { + $suffix = " ($suffix)"; + } + + $payment_method_title = isset( $payment_method_titles[ $payment_request_type ] ) ? $payment_method_titles[ $payment_request_type ] : 'Payment Request'; + $order->set_payment_method_title( $payment_method_title . $suffix ); + } + + /** + * 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-Tokenized-Cart' ) ) { + return $response; + } + + // header added as additional layer of security. + $nonce = $request->get_header( 'X-WooPayments-Tokenized-Cart-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. + $is_update_customer_route = $request->get_route() === '/wc/store/v1/cart/update-customer'; + if ( $is_update_customer_route ) { + 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_ece_address_state_data( $request_data['shipping_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'shipping_address', $this->transform_ece_address_postcode_data( $request_data['shipping_address'] ) ); + } + } + if ( isset( $request_data['billing_address'] ) ) { + $request->set_param( 'billing_address', $this->transform_ece_address_state_data( $request_data['billing_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'billing_address', $this->transform_ece_address_postcode_data( $request_data['billing_address'] ) ); + } + } + + return $response; + } + + /** + * Transform a GooglePay/ApplePay state 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_ece_address_state_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->express_checkout_button_helper->get_normalized_state( $state, $country ); + } + + return $address; + } + + /** + * Transform a GooglePay/ApplePay postcode 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_ece_address_postcode_data( $address ) { + $country = $address['country'] ?? ''; + if ( empty( $country ) ) { + return $address; + } + + // Normalizes postal code in case of redacted data from Apple Pay or Google Pay. + $postcode = $address['postcode'] ?? ''; + if ( ! empty( $postcode ) ) { + $address['postcode'] = $this->express_checkout_button_helper->get_normalized_postal_code( $postcode, $country ); + } + + return $address; + } } 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 6a0113d2048..3e18af89016 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 @@ -231,15 +231,19 @@ public function scripts() { 'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ), ], 'nonce' => [ - 'get_cart_details' => wp_create_nonce( 'wcpay-get-cart-details' ), - 'shipping' => wp_create_nonce( 'wcpay-payment-request-shipping' ), - 'update_shipping' => wp_create_nonce( 'wcpay-update-shipping-method' ), - 'checkout' => wp_create_nonce( 'woocommerce-process_checkout' ), - 'add_to_cart' => wp_create_nonce( 'wcpay-add-to-cart' ), - 'empty_cart' => wp_create_nonce( 'wcpay-empty-cart' ), - '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' ), + 'get_cart_details' => wp_create_nonce( 'wcpay-get-cart-details' ), + 'shipping' => wp_create_nonce( 'wcpay-payment-request-shipping' ), + 'update_shipping' => wp_create_nonce( 'wcpay-update-shipping-method' ), + 'checkout' => wp_create_nonce( 'woocommerce-process_checkout' ), + 'add_to_cart' => wp_create_nonce( 'wcpay-add-to-cart' ), + 'empty_cart' => wp_create_nonce( 'wcpay-empty-cart' ), + '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' ), + // needed to communicate via the Store API. + 'tokenized_cart_nonce' => wp_create_nonce( 'woopayments_tokenized_cart_nonce' ), + 'tokenized_cart_session_nonce' => wp_create_nonce( 'woopayments_tokenized_cart_session_nonce' ), + 'store_api_nonce' => wp_create_nonce( 'wc_store_api' ), ], 'checkout' => [ 'currency_code' => strtolower( get_woocommerce_currency() ),