diff --git a/changelog.txt b/changelog.txt index 9acb462bac0..ec139f830ba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,35 @@ *** WooPayments Changelog *** += 7.7.0 - 2024-05-29 = +* Add - Add share key query param when sending data to Stripe KYC. +* Add - Add the WooPay Direct Checkout flow to the blocks mini cart widget. +* Add - feat: add multi-currency support to Store API. +* Add - feat: error message on 1M+ amount. +* Add - feat: tokenized cart PRBs on PDPs via feature flag. +* Add - Render ECE buttons behind a feature flag. +* Fix - Charm pricing and rounding options corrected for all currencies that aren't presented with decimal points. +* Fix - Fix "Pay for order" infinite loading when submitting form without payment details. +* Fix - fix: remove WooPay checkout pages scripts from non-checkout pages. +* Fix - fix: settings notices consistency. +* Fix - fix: Store API tokenized cart nonce verification. +* Fix - Fix a bug in Tracks where shopper events are not fired properly. +* Fix - Fix ECE error in the blocks checkout when PRBs are disabled. +* Fix - Fix Payment block render error while editing the block checkout page. +* Fix - Fix shortcode orders when using WooPay Direct Checkout. +* Fix - Improve visibility of WooPay button on light and outline button themes. +* Fix - Updating saved payment method billing address before processing the payment. +* Update - Do not auto-redirect to WooPay on page load. +* Update - Pass previous exception with exception. +* Update - Removed deprecated deposit_status key from account status. +* Update - Remove public key encryption setting from WooPayments settings. +* Update - Update XPF currency formatting. +* Dev - Add command to run QIT PHPStan tests. +* Dev - Add local release package support for PHPStan. +* Dev - Bump tested up to version for WP to 6.5 and WC to 8.9.1. +* Dev - Fix Klarna E2E tests. +* Dev - Guarantee REST intialization on REST request context (avoiding rest_preload_api_request context). +* Dev - Upgrade jetpack sync package version. + = 7.6.0 - 2024-05-08 = * Add - Add additional data to Compatibility service * Add - Add User Satisfaction Survey for Payments Overview Widget diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index 869bed5a9a5..6a088a9c5c1 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -8,8 +8,7 @@ import { getPaymentRequestData, getPaymentRequestAjaxURL, buildAjaxURL, -} from '../../payment-request/utils'; -import { decryptClientSecret } from '../utils/encryption'; +} from 'utils/express-checkout'; /** * Handles generic connections to the server and Stripe. @@ -117,90 +116,6 @@ export default class WCPayAPI { } ); } - /** - * Generates a Stripe payment method. - * - * @param {Object} elements A hash of all Stripe elements, used to enter card data. - * @param {Object} preparedCustomerData Default values for customer data, used on pages like Pay for Order. - * @return {Object} A request object, which will be prepared and then `.send()`. - */ - generatePaymentMethodRequest( elements, preparedCustomerData = {} ) { - const stripe = this.getStripe(); - - return new ( class { - constructor() { - this.args = { - ...elements, - billing_details: { - address: {}, - }, - }; - } - - /** - * Prepares a value that's been loaded from inputs, - * uses a default value if none is present. - * - * @param {string} name The key of the value. - * @param {mixed} value The value to sanitize. - * @return {mixed} The sanitized value, `undefined` if not present. - */ - prepareValue( name, value ) { - // Fall back to the value in `preparedCustomerData`. - if ( typeof value === 'undefined' || value.length === 0 ) { - value = preparedCustomerData[ name ]; // `undefined` if not set. - } - - if ( typeof value !== 'undefined' && value.length > 0 ) { - return value; - } - } - - /** - * Updates a billing detail within the request. - * - * @param {string} name The name of the billing value. - * @param {string} value The actual value. - */ - setBillingDetail( name, value ) { - const preparedValue = this.prepareValue( name, value ); - if ( typeof preparedValue !== 'undefined' ) { - this.args.billing_details[ name ] = preparedValue; - } - } - - /** - * Updates an address detail within the request. - * - * @param {string} name The name of the address value. - * @param {string} value The actual value. - */ - setAddressDetail( name, value ) { - const preparedValue = this.prepareValue( name, value ); - if ( typeof preparedValue !== 'undefined' ) { - this.args.billing_details.address[ name ] = preparedValue; - } - } - - /** - * Sends the request to Stripe once everything is ready. - * - * @return {Object} The payment method object if successfully loaded. - */ - send() { - return stripe - .createPaymentMethod( this.args ) - .then( ( paymentMethod ) => { - if ( paymentMethod.error ) { - throw paymentMethod.error; - } - - return paymentMethod; - } ); - } - } )(); - } - /** * Extracts the details about a payment intent from the redirect URL, * and displays the intent confirmation modal (if needed). @@ -253,7 +168,7 @@ export default class WCPayAPI { // use the regular getStripe function. if ( isSetupIntent ) { return this.getStripe().handleNextAction( { - clientSecret: decryptClientSecret( clientSecret ), + clientSecret: clientSecret, } ); } @@ -264,18 +179,13 @@ export default class WCPayAPI { publishableKey, locale, accountIdForIntentConfirmation - ).confirmCardPayment( - decryptClientSecret( - clientSecret, - accountIdForIntentConfirmation - ) - ); + ).confirmCardPayment( clientSecret ); } // When not dealing with a setup intent or woopay we need to force an account // specific request in Stripe. return this.getStripe( true ).handleNextAction( { - clientSecret: decryptClientSecret( clientSecret ), + clientSecret: clientSecret, } ); }; @@ -353,9 +263,7 @@ export default class WCPayAPI { } return this.getStripe() - .confirmCardSetup( - decryptClientSecret( response.data.client_secret ) - ) + .confirmCardSetup( response.data.client_secret ) .then( ( confirmedSetupIntent ) => { const { setupIntent, error } = confirmedSetupIntent; if ( error ) { diff --git a/client/checkout/api/test/index.test.js b/client/checkout/api/test/index.test.js index 3ed3cf22284..c725985fedd 100644 --- a/client/checkout/api/test/index.test.js +++ b/client/checkout/api/test/index.test.js @@ -3,13 +3,13 @@ */ import WCPayAPI from '..'; import request from 'wcpay/checkout/utils/request'; -import { buildAjaxURL } from 'wcpay/payment-request/utils'; +import { buildAjaxURL } from 'wcpay/utils/express-checkout'; import { getConfig } from 'wcpay/utils/checkout'; jest.mock( 'wcpay/checkout/utils/request', () => jest.fn( () => Promise.resolve( {} ).finally( () => {} ) ) ); -jest.mock( 'wcpay/payment-request/utils', () => ( { +jest.mock( 'wcpay/utils/express-checkout', () => ( { buildAjaxURL: jest.fn(), } ) ); jest.mock( 'wcpay/utils/checkout', () => ( { diff --git a/client/checkout/blocks/generate-payment-method.js b/client/checkout/blocks/generate-payment-method.js deleted file mode 100644 index 5908e743784..00000000000 --- a/client/checkout/blocks/generate-payment-method.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Internal dependencies - */ -import { PAYMENT_METHOD_NAME_CARD } from '../constants.js'; - -/** - * Handles the payment method generation action. - * - * @param {WCPayAPI} api The API class that is used to connect both with the server and Stripe. - * @param {Object} elements A hash, containing all Stripe card elements. - * @param {Object} billingData The billing data, which was collected from the checkout block. - * @param {string} fingerprint User fingerprint. - * - * @return {Object} The `onPaymentSetup` response object, including a type and meta data/error message. - */ -const generatePaymentMethod = async ( - api, - elements, - billingData, - fingerprint -) => { - const request = api.generatePaymentMethodRequest( elements ); - - request.setBillingDetail( - 'name', - ( billingData.first_name + ' ' + billingData.last_name ).trim() - ); - request.setBillingDetail( 'email', billingData.email ); - request.setBillingDetail( 'phone', billingData.phone ); - request.setAddressDetail( 'city', billingData.city ); - request.setAddressDetail( 'country', billingData.country ); - request.setAddressDetail( 'line1', billingData.address_1 ); - request.setAddressDetail( 'line2', billingData.address_2 ); - request.setAddressDetail( 'postal_code', billingData.postcode ); - request.setAddressDetail( 'state', billingData.state ); - - try { - const { - paymentMethod: { id }, - } = await request.send(); - - const fraudPreventionToken = window.wcpayFraudPreventionToken; - - return { - type: 'success', - meta: { - paymentMethodData: { - payment_method: PAYMENT_METHOD_NAME_CARD, - 'wcpay-payment-method': id, - 'wcpay-fraud-prevention-token': fraudPreventionToken ?? '', - 'wcpay-fingerprint': fingerprint, - }, - }, - }; - } catch ( error ) { - return { - type: 'error', - message: error.message, - }; - } -}; - -export default generatePaymentMethod; diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 6baad3bdbea..0e26e9ed4b1 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -19,6 +19,7 @@ import PaymentMethodLabel from './payment-method-label'; import request from '../utils/request'; import enqueueFraudScripts from 'fraud-scripts'; import paymentRequestPaymentMethod from '../../payment-request/blocks'; +import expressCheckoutElementPaymentMethod from '../../express-checkout/blocks'; import { PAYMENT_METHOD_NAME_CARD, PAYMENT_METHOD_NAME_BANCONTACT, @@ -154,6 +155,7 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); +registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); window.addEventListener( 'load', () => { enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); addCheckoutTracking(); diff --git a/client/checkout/blocks/payment-elements.js b/client/checkout/blocks/payment-elements.js index b687e407851..7bc9c3a1372 100644 --- a/client/checkout/blocks/payment-elements.js +++ b/client/checkout/blocks/payment-elements.js @@ -1,3 +1,11 @@ +/** + * External dependencies + */ +import { useEffect, useState, RawHTML } from '@wordpress/element'; +import { Elements } from '@stripe/react-stripe-js'; +// eslint-disable-next-line import/no-unresolved +import { StoreNotice } from '@woocommerce/blocks-checkout'; + /** * Internal dependencies */ @@ -6,14 +14,16 @@ import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; import { getUPEConfig } from 'wcpay/utils/checkout'; import { useFingerprint } from './hooks'; import { LoadableBlock } from 'wcpay/components/loadable'; -import { Elements } from '@stripe/react-stripe-js'; -import { useEffect, useState } from 'react'; import PaymentProcessor from './payment-processor'; import { getPaymentMethodTypes } from 'wcpay/checkout/utils/upe'; const PaymentElements = ( { api, ...props } ) => { const stripe = api.getStripeForUPE( props.paymentMethodId ); const [ errorMessage, setErrorMessage ] = useState( null ); + const [ + paymentProcessorLoadErrorMessage, + setPaymentProcessorLoadErrorMessage, + ] = useState( undefined ); const [ appearance, setAppearance ] = useState( getUPEConfig( 'wcBlocksUPEAppearance' ) ); @@ -63,10 +73,23 @@ const PaymentElements = ( { api, ...props } ) => { fonts: fontRules, } } > + { paymentProcessorLoadErrorMessage?.error?.message && ( +
+ + + { + paymentProcessorLoadErrorMessage.error + .message + } + + +
+ ) } diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index 35cb4e019fa..3b6feac6424 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -18,15 +18,19 @@ export default ( { const bnplMethods = [ 'affirm', 'afterpay_clearpay', 'klarna' ]; // Stripe expects the amount to be sent as the minor unit of 2 digits. - const amount = normalizeCurrencyToMinorUnit( - cartData.totals.total_price, - cartData.totals.currency_minor_unit + const amount = parseInt( + normalizeCurrencyToMinorUnit( + cartData.totals.total_price, + cartData.totals.currency_minor_unit + ), + 10 ); // Customer's country or base country of the store. const currentCountry = cartData.billingAddress.country || - window.wcBlocksCheckoutData.storeCountry; + window.wcBlocksCheckoutData?.storeCountry || + 'US'; return ( <> @@ -34,7 +38,9 @@ export default ( { { upeConfig.title } { bnplMethods.includes( upeName ) && ( upeConfig.countries.length === 0 || - upeConfig.countries.includes( currentCountry ) ) && ( + upeConfig.countries.includes( currentCountry ) ) && + amount > 0 && + currentCountry && ( <> { return window.wcpayFraudPreventionToken ?? ''; }; +const noop = () => null; + const PaymentProcessor = ( { api, activePaymentMethod, @@ -60,10 +62,11 @@ const PaymentProcessor = ( { errorMessage, shouldSavePayment, fingerprint, + onLoadError = noop, } ) => { const stripe = useStripe(); const elements = useElements(); - const isPaymentElementCompleteRef = useRef( false ); + const isPaymentInformationCompleteRef = useRef( false ); const paymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); const isTestMode = getUPEConfig( 'testMode' ); @@ -137,7 +140,7 @@ const PaymentProcessor = ( { return; } - if ( ! isPaymentElementCompleteRef.current ) { + if ( ! isPaymentInformationCompleteRef.current ) { return { type: 'error', message: __( @@ -234,8 +237,8 @@ const PaymentProcessor = ( { shouldSavePayment ); - const updatePaymentElementCompletionStatus = ( event ) => { - isPaymentElementCompleteRef.current = event.complete; + const setPaymentInformationCompletionStatus = ( event ) => { + isPaymentInformationCompleteRef.current = event.complete; }; return ( @@ -253,7 +256,8 @@ const PaymentProcessor = ( { shouldSavePayment, paymentMethodsConfig ) } - onChange={ updatePaymentElementCompletionStatus } + onLoadError={ onLoadError } + onChange={ setPaymentInformationCompletionStatus } className="wcpay-payment-element" /> diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index 704b0f1f97d..204a84adc10 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ @@ -34,6 +39,7 @@ for ( const paymentMethodType in getUPEConfig( 'paymentMethodsConfig' ) ) { gatewayUPEComponents[ paymentMethodType ] = { elements: null, upeElement: null, + isPaymentInformationComplete: false, }; } @@ -62,8 +68,8 @@ async function initializeAppearance( api ) { * * @param {Object} $form The jQuery object for the form. */ -export function blockUI( $form ) { - $form.addClass( 'processing' ).block( { +export async function blockUI( $form ) { + await $form.addClass( 'processing' ).block( { message: null, overlayCSS: { background: '#fff', @@ -400,6 +406,27 @@ export async function mountStripePaymentElement( api, domElement ) { gatewayUPEComponents[ paymentMethodType ].upeElement || ( await createStripePaymentElement( api, paymentMethodType ) ); upeElement.mount( domElement ); + upeElement.on( 'change', ( e ) => { + gatewayUPEComponents[ paymentMethodType ].isPaymentInformationComplete = + e.complete; + } ); + upeElement.on( 'loaderror', ( e ) => { + // unset any styling to ensure the WC error message wrapper can take more width. + domElement.style.padding = '0'; + // creating a new element to be added to the DOM, so that the message can be displayed. + const messageWrapper = document.createElement( 'div' ); + messageWrapper.classList.add( 'woocommerce-error' ); + messageWrapper.innerHTML = e.error.message; + messageWrapper.style.margin = '0'; + domElement.appendChild( messageWrapper ); + // hiding any "save payment method" checkboxes. + const savePaymentMethodWrapper = domElement + .closest( '.payment_box' ) + ?.querySelector( '.woocommerce-SavedPaymentMethods-saveNew' ); + if ( savePaymentMethodWrapper ) { + savePaymentMethodWrapper.style.display = 'none'; + } + } ); } export async function mountStripePaymentMethodMessagingElement( @@ -493,12 +520,24 @@ export const processPayment = ( return; } - blockUI( $form ); - - const elements = gatewayUPEComponents[ paymentMethodType ].elements; - ( async () => { try { + await blockUI( $form ); + + const { + elements, + isPaymentInformationComplete, + } = gatewayUPEComponents[ paymentMethodType ]; + + if ( ! isPaymentInformationComplete ) { + throw new Error( + __( + 'Your payment information is incomplete.', + 'woocommerce-payments' + ) + ); + } + await validateElements( elements ); const paymentMethodObject = await createStripePaymentMethod( api, diff --git a/client/checkout/classic/test/payment-processing.test.js b/client/checkout/classic/test/payment-processing.test.js index df5ac6ef2ca..ad8a24315b9 100644 --- a/client/checkout/classic/test/payment-processing.test.js +++ b/client/checkout/classic/test/payment-processing.test.js @@ -65,10 +65,25 @@ const mockUpdateFunction = jest.fn(); const mockMountFunction = jest.fn(); +let eventHandlersFromElementsCreate = {}; const mockCreateFunction = jest.fn( () => ( { mount: mockMountFunction, update: mockUpdateFunction, + on: ( event, handler ) => { + if ( ! eventHandlersFromElementsCreate[ event ] ) { + eventHandlersFromElementsCreate[ event ] = []; + } + eventHandlersFromElementsCreate[ event ].push( handler ); + }, } ) ); +const callAllCreateHandlersWith = ( event, ...args ) => { + eventHandlersFromElementsCreate[ event ]?.forEach( ( handler ) => { + handler.apply( null, args ); + } ); +}; +const markAllPaymentElementsAsComplete = () => { + callAllCreateHandlersWith( 'change', { complete: true } ); +}; const mockSubmit = jest.fn( () => ( { then: jest.fn(), @@ -95,6 +110,7 @@ describe( 'Stripe Payment Element mounting', () => { beforeEach( () => { mockDomElement = document.createElement( 'div' ); + eventHandlersFromElementsCreate = {}; getUPEConfig.mockImplementation( ( argument ) => { if ( argument === 'wcBlocksUPEAppearance' || @@ -380,6 +396,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const mockJqueryForm = { submit: jest.fn(), @@ -426,6 +443,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -439,6 +457,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -467,6 +487,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -480,6 +501,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -504,6 +527,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -517,6 +541,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -538,6 +564,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -551,6 +578,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -570,6 +599,7 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); + markAllPaymentElementsAsComplete(); const addPaymentMethodForm = { submit: jest.fn(), @@ -583,6 +613,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, addPaymentMethodForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), diff --git a/client/checkout/constants.js b/client/checkout/constants.js index b2d4ac88fdc..59bebcbac12 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -13,6 +13,8 @@ export const PAYMENT_METHOD_NAME_AFTERPAY = export const PAYMENT_METHOD_NAME_KLARNA = 'woocommerce_payments_klarna'; export const PAYMENT_METHOD_NAME_PAYMENT_REQUEST = 'woocommerce_payments_payment_request'; +export const PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT = + 'woocommerce_payments_express_checkout'; export const PAYMENT_METHOD_NAME_WOOPAY_EXPRESS_CHECKOUT = 'woocommerce_payments_woopay_express_checkout'; export const WC_STORE_CART = 'wc/store/cart'; diff --git a/client/checkout/utils/encryption.js b/client/checkout/utils/encryption.js deleted file mode 100644 index 1bb59e94ed5..00000000000 --- a/client/checkout/utils/encryption.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import Utf8 from 'crypto-js/enc-utf8'; -import AES from 'crypto-js/aes'; -import Pkcs7 from 'crypto-js/pad-pkcs7'; -import { getConfig } from 'wcpay/utils/checkout'; - -export const decryptClientSecret = function ( - encryptedValue, - stripeAccountId = null -) { - if ( - getConfig( 'isClientEncryptionEnabled' ) && - encryptedValue.length > 3 && - encryptedValue.slice( 0, 3 ) !== 'pi_' && - encryptedValue.slice( 0, 5 ) !== 'seti_' - ) { - stripeAccountId = stripeAccountId || getConfig( 'accountId' ); - return Utf8.stringify( - AES.decrypt( - encryptedValue, - Utf8.parse( stripeAccountId.slice( 5 ) ), - { - iv: Utf8.parse( 'WC'.repeat( 8 ) ), - padding: Pkcs7, - } - ) - ); - } - return encryptedValue; -}; diff --git a/client/checkout/woopay/direct-checkout/index.js b/client/checkout/woopay/direct-checkout/index.js index b8795cfa3ea..c7db345ab9f 100644 --- a/client/checkout/woopay/direct-checkout/index.js +++ b/client/checkout/woopay/direct-checkout/index.js @@ -9,61 +9,94 @@ import { debounce } from 'lodash'; * Internal dependencies */ import { WC_STORE_CART } from 'wcpay/checkout/constants'; -import { waitMilliseconds } from 'wcpay/checkout/woopay/direct-checkout/utils'; +import { + waitMilliseconds, + waitForSelector, +} from 'wcpay/checkout/woopay/direct-checkout/utils'; import WooPayDirectCheckout from 'wcpay/checkout/woopay/direct-checkout/woopay-direct-checkout'; import { shouldSkipWooPay } from 'wcpay/checkout/woopay/utils'; let isThirdPartyCookieEnabled = false; -window.addEventListener( 'load', async () => { - if ( - ! WooPayDirectCheckout.isWooPayDirectCheckoutEnabled() || - shouldSkipWooPay() - ) { +/** + * Handle the WooPay direct checkout for the given checkout buttons. + * + * @param {HTMLElement[]} checkoutButtons An array of checkout button elements. + */ +const handleWooPayDirectCheckout = async ( checkoutButtons ) => { + if ( ! checkoutButtons ) { return; } - WooPayDirectCheckout.init(); - - isThirdPartyCookieEnabled = await WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled(); - const checkoutElements = WooPayDirectCheckout.getCheckoutRedirectElements(); if ( isThirdPartyCookieEnabled ) { if ( await WooPayDirectCheckout.isUserLoggedIn() ) { WooPayDirectCheckout.maybePrefetchEncryptedSessionData(); - WooPayDirectCheckout.redirectToWooPay( checkoutElements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + checkoutButtons, + true + ); } return; } // Pass false to indicate we are not sure if the user is logged in or not. - WooPayDirectCheckout.redirectToWooPay( checkoutElements, false ); -} ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + checkoutButtons, + false + ); +}; -jQuery( ( $ ) => { - $( document.body ).on( 'updated_cart_totals', async () => { - if ( - ! WooPayDirectCheckout.isWooPayDirectCheckoutEnabled() || - shouldSkipWooPay() - ) { - return; - } +/** + * Add an event listener to the mini cart checkout button. + */ +const addMiniCartEventListener = () => { + const checkoutButton = WooPayDirectCheckout.getMiniCartProceedToCheckoutButton(); + handleWooPayDirectCheckout( [ checkoutButton ] ); +}; - // When "updated_cart_totals" is triggered, the classic 'Proceed to Checkout' button is - // re-rendered. So, the click-event listener needs to be re-attached to the new button. - const checkoutButton = WooPayDirectCheckout.getClassicProceedToCheckoutButton(); - if ( isThirdPartyCookieEnabled ) { - if ( await WooPayDirectCheckout.isUserLoggedIn() ) { - WooPayDirectCheckout.maybePrefetchEncryptedSessionData(); - WooPayDirectCheckout.redirectToWooPay( [ checkoutButton ] ); - } +/** + * If the mini cart widget is available on the page, observe when the drawer element gets added to the DOM. + * + * As of today, no window events are triggered when the mini cart is opened or closed, + * nor there are attribute changes to the "open" button, so we have to rely on a MutationObserver + * attached to the `document.body`, which is where the mini cart drawer element is added. + */ +const maybeObserveMiniCart = () => { + // Check if the widget is available on the page. + if ( + ! document.querySelector( '[data-block-name="woocommerce/mini-cart"]' ) + ) { + return; + } - return; + // Create a MutationObserver to check when the mini cart drawer is added to the DOM. + const observer = new MutationObserver( ( mutations ) => { + for ( const mutation of mutations ) { + if ( mutation?.addedNodes?.length > 0 ) { + for ( const node of mutation.addedNodes ) { + // Check if the mini cart drawer parent selector was added to the DOM. + if ( + node.nodeType === 1 && + node.matches( + '.wc-block-components-drawer__screen-overlay' + ) + ) { + // Wait until the button is rendered and add the event listener to it. + waitForSelector( + WooPayDirectCheckout.redirectElements + .BLOCKS_MINI_CART_PROCEED_BUTTON, + addMiniCartEventListener + ); + return; + } + } + } } - - WooPayDirectCheckout.redirectToWooPay( [ checkoutButton ], true ); } ); -} ); + + observer.observe( document.body, { childList: true } ); +}; /** * Determines whether the encrypted session data should be prefetched. @@ -173,22 +206,51 @@ const removeItemCallback = async ( { product } ) => { } }; -// Note, although the following hooks are prefixed with 'experimental__', they will be -// graduated to stable in the near future (it'll include the 'experimental__' prefix). -addAction( - 'experimental__woocommerce_blocks-cart-add-item', - 'wcpay_woopay_direct_checkout', - addItemCallback -); - -addAction( - 'experimental__woocommerce_blocks-cart-set-item-quantity', - 'wcpay_woopay_direct_checkout', - debounceSetItemQtyCallback -); - -addAction( - 'experimental__woocommerce_blocks-cart-remove-item', - 'wcpay_woopay_direct_checkout', - removeItemCallback -); +window.addEventListener( 'load', async () => { + if ( shouldSkipWooPay() ) { + return; + } + + WooPayDirectCheckout.init(); + + isThirdPartyCookieEnabled = await WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled(); + + // Note, although the following hooks are prefixed with 'experimental__', they will be + // graduated to stable in the near future (it'll include the 'experimental__' prefix). + addAction( + 'experimental__woocommerce_blocks-cart-add-item', + 'wcpay_woopay_direct_checkout', + addItemCallback + ); + + addAction( + 'experimental__woocommerce_blocks-cart-set-item-quantity', + 'wcpay_woopay_direct_checkout', + debounceSetItemQtyCallback + ); + + addAction( + 'experimental__woocommerce_blocks-cart-remove-item', + 'wcpay_woopay_direct_checkout', + removeItemCallback + ); + + // If the mini cart is available, check when it's opened so we can add the event listener to the mini cart's checkout button. + maybeObserveMiniCart(); + + const checkoutButtons = WooPayDirectCheckout.getCheckoutButtonElements(); + handleWooPayDirectCheckout( checkoutButtons ); +} ); + +jQuery( ( $ ) => { + $( document.body ).on( 'updated_cart_totals', async () => { + if ( shouldSkipWooPay() ) { + return; + } + + // When "updated_cart_totals" is triggered, the classic 'Proceed to Checkout' button is + // re-rendered. So, the click-event listener needs to be re-attached to the new button. + const checkoutButton = WooPayDirectCheckout.getClassicProceedToCheckoutButton(); + handleWooPayDirectCheckout( [ checkoutButton ] ); + } ); +} ); diff --git a/client/checkout/woopay/direct-checkout/test/index.test.js b/client/checkout/woopay/direct-checkout/test/index.test.js index 1cf516709cc..245c747a1e2 100644 --- a/client/checkout/woopay/direct-checkout/test/index.test.js +++ b/client/checkout/woopay/direct-checkout/test/index.test.js @@ -20,14 +20,13 @@ jest.mock( '@wordpress/hooks', () => ( { jest.mock( 'wcpay/checkout/woopay/direct-checkout/woopay-direct-checkout', () => ( { - isWooPayDirectCheckoutEnabled: jest.fn(), init: jest.fn(), isWooPayThirdPartyCookiesEnabled: jest.fn(), - getCheckoutRedirectElements: jest.fn(), + getCheckoutButtonElements: jest.fn(), isUserLoggedIn: jest.fn(), maybePrefetchEncryptedSessionData: jest.fn(), getClassicProceedToCheckoutButton: jest.fn(), - redirectToWooPay: jest.fn(), + addRedirectToWooPayEventListener: jest.fn(), setEncryptedSessionDataAsNotPrefetched: jest.fn(), } ) ); @@ -53,30 +52,12 @@ describe( 'WooPay direct checkout window "load" event listener', () => { jest.clearAllMocks(); } ); - it( 'does not initialize WooPay direct checkout if not enabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - false - ); - - fireEvent.load( window ); - - await new Promise( ( resolve ) => setImmediate( resolve ) ); - - expect( - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled - ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.init ).not.toHaveBeenCalled(); - } ); - - it( 'calls `redirectToWooPay` method if third-party cookies are enabled and user is logged-in', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method if third-party cookies are enabled and user is logged-in', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( true ); WooPayDirectCheckout.isUserLoggedIn.mockResolvedValue( true ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -90,20 +71,16 @@ describe( 'WooPay direct checkout window "load" event listener', () => { expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - true - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), true ); } ); - it( 'calls `redirectToWooPay` method with "checkout_redirect" if third-party cookies are disabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method with "checkout_redirect" if third-party cookies are disabled', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( false ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -117,10 +94,9 @@ describe( 'WooPay direct checkout window "load" event listener', () => { expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).not.toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - false - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), false ); } ); } ); @@ -129,34 +105,12 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', jest.clearAllMocks(); } ); - it( 'should not proceed if direct checkout is not enabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - false - ); - - fireEvent.load( window ); - - await new Promise( ( resolve ) => setImmediate( resolve ) ); - - $( document.body ).trigger( 'updated_cart_totals' ); - - expect( - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled - ).toHaveBeenCalled(); - expect( - WooPayDirectCheckout.getClassicProceedToCheckoutButton - ).not.toHaveBeenCalled(); - } ); - - it( 'calls `redirectToWooPay` method if third-party cookies are enabled and user is logged-in', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method if third-party cookies are enabled and user is logged-in', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( true ); WooPayDirectCheckout.isUserLoggedIn.mockResolvedValue( true ); - WooPayDirectCheckout.getCheckoutRedirectElements.mockReturnValue( [] ); + WooPayDirectCheckout.getCheckoutButtonElements.mockReturnValue( [] ); fireEvent.load( window ); @@ -172,16 +126,12 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - true - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), true ); } ); - it( 'calls `redirectToWooPay` method with "checkout_redirect" if third-party cookies are disabled', async () => { - WooPayDirectCheckout.isWooPayDirectCheckoutEnabled.mockReturnValue( - true - ); + it( 'calls `addRedirectToWooPayEventListener` method with "checkout_redirect" if third-party cookies are disabled', async () => { WooPayDirectCheckout.isWooPayThirdPartyCookiesEnabled.mockResolvedValue( false ); @@ -203,10 +153,9 @@ describe( 'WooPay direct checkout "updated_cart_totals" jQuery event listener', expect( WooPayDirectCheckout.maybePrefetchEncryptedSessionData ).not.toHaveBeenCalled(); - expect( WooPayDirectCheckout.redirectToWooPay ).toHaveBeenCalledWith( - expect.any( Array ), - false - ); + expect( + WooPayDirectCheckout.addRedirectToWooPayEventListener + ).toHaveBeenCalledWith( expect.any( Array ), false ); } ); } ); diff --git a/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js b/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js index c2f8fbfe147..6d84377cd4b 100644 --- a/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js +++ b/client/checkout/woopay/direct-checkout/test/woopay-direct-checkout.test.js @@ -4,7 +4,7 @@ import WooPayDirectCheckout from '../woopay-direct-checkout'; describe( 'WooPayDirectCheckout', () => { - describe( 'redirectToWooPay', () => { + describe( 'addRedirectToWooPayEventListener', () => { const originalLocation = window.location; let elements; @@ -44,7 +44,7 @@ describe( 'WooPayDirectCheckout', () => { element.addEventListener = jest.fn(); } ); - WooPayDirectCheckout.redirectToWooPay( elements ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( elements ); elements.forEach( ( element ) => { expect( element.addEventListener ).toHaveBeenCalledWith( @@ -55,7 +55,10 @@ describe( 'WooPayDirectCheckout', () => { } ); it( 'should add loading spinner when shortcode cart button is clicked', () => { - WooPayDirectCheckout.redirectToWooPay( elements, false ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + false + ); elements[ 0 ].click(); @@ -71,7 +74,10 @@ describe( 'WooPayDirectCheckout', () => { 'https://woopay.test/woopay?checkout_redirect=1&blog_id=1&session=1&iv=1&hash=1' ); - WooPayDirectCheckout.redirectToWooPay( elements, false ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + false + ); elements[ 0 ].click(); @@ -91,7 +97,10 @@ describe( 'WooPayDirectCheckout', () => { 'https://woopay.test/woopay?platform_checkout_key=1234567890' ); - WooPayDirectCheckout.redirectToWooPay( elements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + true + ); elements[ 0 ].click(); @@ -112,7 +121,10 @@ describe( 'WooPayDirectCheckout', () => { new Error( 'Could not retrieve WooPay checkout URL.' ) ); - WooPayDirectCheckout.redirectToWooPay( elements, true ); + WooPayDirectCheckout.addRedirectToWooPayEventListener( + elements, + true + ); elements[ 0 ].click(); diff --git a/client/checkout/woopay/direct-checkout/utils.js b/client/checkout/woopay/direct-checkout/utils.js index ceaf0f24c7d..e06f4efcc4c 100644 --- a/client/checkout/woopay/direct-checkout/utils.js +++ b/client/checkout/woopay/direct-checkout/utils.js @@ -9,3 +9,31 @@ export const waitMilliseconds = ( ms ) => { setTimeout( resolve, ms ); } ); }; + +/** + * Wait for a selector to be available in the DOM. + * + * In the context of the direct checkout flow, we use this to wait for + * a button to render, that's why the default timeout is set to 2000ms. + * + * @param {string} selector The CSS selector to wait for. + * @param {Function} callback The callback function to be called when the selector is available. + * @param {integer} timeout The timeout in milliseconds. + */ +export const waitForSelector = ( selector, callback, timeout = 2000 ) => { + const startTime = Date.now(); + const checkElement = () => { + if ( Date.now() - startTime > timeout ) { + return; + } + + const element = document.querySelector( selector ); + if ( element ) { + callback( element ); + } else { + requestAnimationFrame( checkElement ); + } + }; + + requestAnimationFrame( checkElement ); +}; diff --git a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js index 7847bd7de74..b44db8d07e6 100644 --- a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js +++ b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js @@ -3,7 +3,7 @@ */ import { getConfig } from 'wcpay/utils/checkout'; import request from 'wcpay/checkout/utils/request'; -import { buildAjaxURL } from 'wcpay/payment-request/utils'; +import { buildAjaxURL } from 'wcpay/utils/express-checkout'; import UserConnect from 'wcpay/checkout/woopay/connect/user-connect'; import SessionConnect from 'wcpay/checkout/woopay/connect/session-connect'; @@ -19,6 +19,8 @@ class WooPayDirectCheckout { CLASSIC_CART_PROCEED_BUTTON: '.wc-proceed-to-checkout .checkout-button', BLOCKS_CART_PROCEED_BUTTON: '.wp-block-woocommerce-proceed-to-checkout-block', + BLOCKS_MINI_CART_PROCEED_BUTTON: + 'a.wp-block-woocommerce-mini-cart-checkout-button-block', }; /** @@ -195,7 +197,7 @@ class WooPayDirectCheckout { * * @return {*[]} The checkout redirect elements. */ - static getCheckoutRedirectElements() { + static getCheckoutButtonElements() { const elements = []; const addElementBySelector = ( selector ) => { const element = document.querySelector( selector ); @@ -226,13 +228,27 @@ class WooPayDirectCheckout { ); } + /** + * Gets the mini cart 'Go to checkout' button. + * + * @return {Element} The mini cart 'Go to checkout' button. + */ + static getMiniCartProceedToCheckoutButton() { + return document.querySelector( + this.redirectElements.BLOCKS_MINI_CART_PROCEED_BUTTON + ); + } + /** * Adds a click-event listener to the given elements that redirects to the WooPay checkout page. * * @param {*[]} elements The elements to add a click-event listener to. * @param {boolean} userIsLoggedIn True if we determined the user is already logged in, false otherwise. */ - static redirectToWooPay( elements, userIsLoggedIn = false ) { + static addRedirectToWooPayEventListener( + elements, + userIsLoggedIn = false + ) { /** * Adds a loading spinner to the given element. * @@ -258,13 +274,23 @@ class WooPayDirectCheckout { }; /** - * Checks if the given element is the checkout button in the cart shortcode. + * Checks if a loading spinner should be added to the given element. * * @param {Element} element The element to check. * - * @return {boolean} True if the element is a checkout button in the cart shortcode. + * @return {boolean} True if a loading spinner should be added. */ - const isCheckoutButtonInCartShortCode = ( element ) => { + const shouldAddLoadingSpinner = ( element ) => { + // If the button is in the mini cart, add a spinner. + if ( + element.classList.contains( + 'wp-block-woocommerce-mini-cart-checkout-button-block' + ) + ) { + return true; + } + + // If the button is in the classic cart, add a spinner. const isCheckoutButton = element.classList.contains( 'checkout-button' ); @@ -288,7 +314,7 @@ class WooPayDirectCheckout { elementState.is_loading = true; - if ( isCheckoutButtonInCartShortCode( element ) ) { + if ( shouldAddLoadingSpinner( element ) ) { addLoadingSpinner( element ); } diff --git a/client/checkout/woopay/email-input-iframe.js b/client/checkout/woopay/email-input-iframe.js index 2538de85008..4166f4d8cdd 100644 --- a/client/checkout/woopay/email-input-iframe.js +++ b/client/checkout/woopay/email-input-iframe.js @@ -5,7 +5,7 @@ import { __ } from '@wordpress/i18n'; import { getConfig } from 'wcpay/utils/checkout'; import { recordUserEvent, getTracksIdentity } from 'tracks'; import request from '../utils/request'; -import { buildAjaxURL } from '../../payment-request/utils'; +import { buildAjaxURL } from 'utils/express-checkout'; import { getTargetElement, validateEmail, @@ -23,7 +23,6 @@ export const handleWooPayEmailInput = async ( const waitTime = 500; const woopayEmailInput = await getTargetElement( field ); const tracksUserId = await getTracksIdentity(); - let hasCheckedLoginSession = false; // If we can't find the input, return. if ( ! woopayEmailInput ) { @@ -34,24 +33,6 @@ export const handleWooPayEmailInput = async ( const parentDiv = woopayEmailInput.parentNode; spinner.classList.add( 'wc-block-components-spinner' ); - // Make the login session iframe wrapper. - const loginSessionIframeWrapper = document.createElement( 'div' ); - loginSessionIframeWrapper.setAttribute( 'role', 'dialog' ); - loginSessionIframeWrapper.setAttribute( 'aria-modal', 'true' ); - - // Make the login session iframe. - const loginSessionIframe = document.createElement( 'iframe' ); - loginSessionIframe.title = __( - 'WooPay Login Session', - 'woocommerce-payments' - ); - loginSessionIframe.classList.add( 'woopay-login-session-iframe' ); - - // To prevent twentytwenty.intrinsicRatioVideos from trying to resize the iframe. - loginSessionIframe.classList.add( 'intrinsic-ignore' ); - - loginSessionIframeWrapper.insertBefore( loginSessionIframe, null ); - // Make the otp iframe wrapper. const iframeWrapper = document.createElement( 'div' ); iframeWrapper.setAttribute( 'role', 'dialog' ); @@ -442,54 +423,7 @@ export const handleWooPayEmailInput = async ( } ); }; - const closeLoginSessionIframe = () => { - loginSessionIframeWrapper.remove(); - loginSessionIframe.classList.remove( 'open' ); - woopayEmailInput.focus( { - preventScroll: true, - } ); - - // Check the initial value of the email input and trigger input validation. - if ( validateEmail( woopayEmailInput.value ) ) { - woopayLocateUser( woopayEmailInput.value ); - } - }; - - const openLoginSessionIframe = ( email ) => { - const emailParam = new URLSearchParams(); - - if ( validateEmail( email ) ) { - parentDiv.insertBefore( spinner, woopayEmailInput ); - emailParam.append( 'email', email ); - emailParam.append( 'test_mode', !! getConfig( 'testMode' ) ); - } - - loginSessionIframe.src = `${ getConfig( - 'woopayHost' - ) }/login-session?${ emailParam.toString() }`; - - // Insert the wrapper into the DOM. - parentDiv.insertBefore( loginSessionIframeWrapper, null ); - - // Focus the iframe. - loginSessionIframe.focus(); - - // fallback to close the login session iframe in case failed to receive event - // via postMessage. - setTimeout( () => { - if ( ! hasCheckedLoginSession ) { - closeLoginSessionIframe(); - } - }, 15000 ); - }; - woopayEmailInput.addEventListener( 'input', ( e ) => { - if ( ! hasCheckedLoginSession && ! customerClickedBackButton ) { - openLoginSessionIframe( woopayEmailInput.value ); - - return; - } - const email = e.currentTarget.value; clearTimeout( timer ); @@ -506,52 +440,7 @@ export const handleWooPayEmailInput = async ( if ( ! getConfig( 'woopayHost' ).startsWith( e.origin ) ) { return; } - switch ( e.data.action ) { - case 'auto_redirect_to_platform_checkout': - case 'auto_redirect_to_woopay': - hasCheckedLoginSession = true; - api.initWooPay( - e.data.userEmail, - e.data.platformCheckoutUserSession - ) - .then( ( response ) => { - if ( response.result === 'success' ) { - loginSessionIframeWrapper.classList.add( - 'woopay-login-session-iframe-wrapper' - ); - loginSessionIframe.classList.add( 'open' ); - recordUserEvent( 'checkout_woopay_auto_redirect' ); - spinner.remove(); - // Do nothing if the iframe has been closed. - if ( - ! document.querySelector( - '.woopay-login-session-iframe' - ) - ) { - return; - } - window.location = response.url; - } else { - closeLoginSessionIframe(); - } - } ) - .catch( ( err ) => { - // Only show the error if it's not an AbortError, - // it occurs when the fetch request is aborted because user - // clicked the Place Order button while loading. - if ( err.name !== 'AbortError' ) { - showErrorMessage(); - } - } ) - .finally( () => { - spinner.remove(); - } ); - break; - case 'close_auto_redirection_modal': - hasCheckedLoginSession = true; - closeLoginSessionIframe(); - break; case 'redirect_to_woopay_skip_session_init': if ( e.data.redirectUrl ) { deleteSkipWooPayCookie(); @@ -562,10 +451,18 @@ export const handleWooPayEmailInput = async ( break; case 'redirect_to_platform_checkout': case 'redirect_to_woopay': - api.initWooPay( + const promise = api.initWooPay( woopayEmailInput.value, e.data.platformCheckoutUserSession - ) + ); + + // The component on WooPay re-renders sending the `redirect_to_platform_checkout` message twice. + // `api.initWooPay` skips the request the second time and returns undefined. + if ( ! promise ) { + break; + } + + promise .then( ( response ) => { // Do nothing if the iframe has been closed. if ( @@ -633,25 +530,7 @@ export const handleWooPayEmailInput = async ( } } ); - if ( ! customerClickedBackButton ) { - const hasWcPayElementOnBlocks = document.getElementById( - 'radio-control-wc-payment-method-options-woocommerce_payments' - ); - const hasWcPayElementOnShortcode = document.getElementById( - 'payment_method_woocommerce_payments' - ); - const hasWCPayPaymentMethod = - hasWcPayElementOnBlocks || hasWcPayElementOnShortcode; - - // Check if user already has a WooPay login session and only open the iframe if there is WCPay. - if ( - ! hasCheckedLoginSession && - hasWCPayPaymentMethod && - ! getConfig( 'isWooPayDirectCheckoutEnabled' ) - ) { - openLoginSessionIframe( woopayEmailInput.value ); - } - } else { + if ( customerClickedBackButton ) { // Dispatch an event declaring this user exists as returned via back button. Wait for the window to load. setTimeout( () => { dispatchUserExistEvent( true ); diff --git a/client/checkout/woopay/express-button/express-checkout-iframe.js b/client/checkout/woopay/express-button/express-checkout-iframe.js index a5e4dcc006f..2454564c88c 100644 --- a/client/checkout/woopay/express-button/express-checkout-iframe.js +++ b/client/checkout/woopay/express-button/express-checkout-iframe.js @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; import { getConfig } from 'utils/checkout'; import request from 'wcpay/checkout/utils/request'; import { showErrorMessage } from 'wcpay/checkout/woopay/express-button/utils'; -import { buildAjaxURL } from 'wcpay/payment-request/utils'; +import { buildAjaxURL } from 'wcpay/utils/express-checkout'; import { getTargetElement, validateEmail, @@ -172,7 +172,7 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { userEmail = email; urlParams.append( 'email', email ); } - urlParams.append( 'is_blocks', !! wcSettings.wcBlocksConfig ); + urlParams.append( 'is_blocks', !! window.wcSettings?.wcBlocksConfig ); urlParams.append( 'is_express', 'true' ); urlParams.append( 'express_context', context ); urlParams.append( 'source_url', window.location.href ); diff --git a/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js b/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js index b485ae9d8dd..b3ac7c41f3f 100644 --- a/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js +++ b/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js @@ -36,7 +36,6 @@ jest.mock( 'tracks', () => ( { events: { WOOPAY_EMAIL_CHECK: 'checkout_email_address_woopay_check', WOOPAY_OFFERED: 'checkout_woopay_save_my_info_offered', - WOOPAY_AUTO_REDIRECT: 'checkout_woopay_auto_redirect', WOOPAY_SKIPPED: 'woopay_skipped', WOOPAY_BUTTON_LOAD: 'woopay_button_load', WOOPAY_BUTTON_CLICK: 'woopay_button_click', diff --git a/client/checkout/woopay/express-button/woopay-first-party-auth.js b/client/checkout/woopay/express-button/woopay-first-party-auth.js index d8e5b3f1d11..89c809a5979 100644 --- a/client/checkout/woopay/express-button/woopay-first-party-auth.js +++ b/client/checkout/woopay/express-button/woopay-first-party-auth.js @@ -3,7 +3,7 @@ */ import SessionConnect from 'wcpay/checkout/woopay/connect/session-connect'; import request from 'wcpay/checkout/utils/request'; -import { buildAjaxURL } from 'wcpay/payment-request/utils'; +import { buildAjaxURL } from 'wcpay/utils/express-checkout'; import { getConfig } from 'wcpay/utils/checkout'; class WooPayFirstPartyAuth { diff --git a/client/checkout/woopay/style.scss b/client/checkout/woopay/style.scss index 67b212331e3..c547e812849 100644 --- a/client/checkout/woopay/style.scss +++ b/client/checkout/woopay/style.scss @@ -129,9 +129,9 @@ sans-serif; letter-spacing: 0.8px; height: 40px; - background: #f2deff !important; + background: $white !important; border: 1px solid $white !important; - color: $studio-woocommerce-purple-60 !important; + color: $studio-black !important; width: 100%; border-radius: 4px; padding-top: 1px; @@ -143,7 +143,7 @@ text-transform: none; &:not( :disabled ):hover { - background: #d9baff !important; + background: #e0e0e0 !important; cursor: pointer; } @@ -196,9 +196,9 @@ } &[data-theme='light-outline'] { - border-color: #674399 !important; + border-color: $studio-black !important; &:not( :disabled ):hover { - background: #d9baff !important; + background: #e0e0e0 !important; } } diff --git a/client/components/payment-activity/index.tsx b/client/components/payment-activity/index.tsx index 4f60bf3b573..6a7e7bad00d 100644 --- a/client/components/payment-activity/index.tsx +++ b/client/components/payment-activity/index.tsx @@ -5,57 +5,94 @@ import * as React from 'react'; import { Card, CardBody, CardHeader } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import interpolateComponents from '@automattic/interpolate-components'; +import moment from 'moment'; /** * Internal dependencies */ import EmptyStateAsset from 'assets/images/payment-activity-empty-state.svg?asset'; -import PaymentActivityData from './payment-activity-data'; +import PaymentActivityDataComponent from './payment-activity-data'; import Survey from './survey'; import { WcPayOverviewSurveyContextProvider } from './survey/context'; +import { usePaymentActivityData } from 'wcpay/data'; +import type { DateRange } from './types'; import './style.scss'; +/** + * This will be replaces in the future with a dynamic date range picker. + */ +const getDateRange = (): DateRange => { + return { + // Subtract 7 days from the current date. + date_start: moment() + .subtract( 7, 'd' ) + .format( 'YYYY-MM-DD\\THH:mm:ss' ), + date_end: moment().format( 'YYYY-MM-DD\\THH:mm:ss' ), + }; +}; + +const PaymentActivityEmptyState: React.FC = () => ( + + + { __( 'Your payment activity', 'woocommerce-payments' ) } + + +
+ +

+ { interpolateComponents( { + mixedString: __( + '{{strong}}No payments…yet!{{/strong}}' + ), + components: { + strong: , + }, + } ) } +

+

+ { __( + "Once your first order comes in, you'll start seeing your payment activity right here.", + 'woocommerce-payments' + ) } +

+
+
+
+); + const PaymentActivity: React.FC = () => { - const { lifetimeTPV } = wcpaySettings; - const hasAtLeastOnePayment = lifetimeTPV > 0; const isOverviewSurveySubmitted = wcpaySettings.isOverviewSurveySubmitted ?? false; + const { paymentActivityData, isLoading } = usePaymentActivityData( { + ...getDateRange(), + timezone: moment( new Date() ).format( 'Z' ), + } ); + + // When not loading and data is undefined, do not show widget. + // This should only happen in 2 occasions: + // 1. Initially on page load, and + // 2. When we get an error from server. + const showWidget = isLoading || paymentActivityData !== undefined; + if ( ! showWidget ) { + return <>; + } + return ( { __( 'Your payment activity', 'woocommerce-payments' ) } - - { hasAtLeastOnePayment && <>{ /* Filters go here */ } } + { /* Filters go here */ } - { hasAtLeastOnePayment ? ( - - ) : ( -
- -

- { interpolateComponents( { - mixedString: __( - '{{strong}}No payments…yet!{{/strong}}' - ), - components: { - strong: , - }, - } ) } -

-

- { __( - "Once your first order comes in, you'll start seeing your payment activity right here.", - 'woocommerce-payments' - ) } -

-
- ) } +
- { ! isOverviewSurveySubmitted && hasAtLeastOnePayment && ( + { ! isOverviewSurveySubmitted && ( @@ -64,4 +101,15 @@ const PaymentActivity: React.FC = () => { ); }; -export default PaymentActivity; +const PaymentActivityWrapper: React.FC = () => { + const { lifetimeTPV } = wcpaySettings; + const hasAtLeastOnePayment = lifetimeTPV > 0; + + if ( ! hasAtLeastOnePayment ) { + return ; + } + + return ; +}; + +export default PaymentActivityWrapper; diff --git a/client/components/payment-activity/payment-activity-data.tsx b/client/components/payment-activity/payment-activity-data.tsx index 20ca61265f3..32ba0ec522d 100644 --- a/client/components/payment-activity/payment-activity-data.tsx +++ b/client/components/payment-activity/payment-activity-data.tsx @@ -13,24 +13,10 @@ import interpolateComponents from '@automattic/interpolate-components'; import InlineNotice from '../inline-notice'; import PaymentDataTile from './payment-data-tile'; import { ClickTooltip } from '../tooltip'; -import { usePaymentActivityData } from 'wcpay/data'; import { getAdminUrl } from 'wcpay/utils'; -import type { DateRange } from './types'; +import type { PaymentActivityData } from 'wcpay/data/payment-activity/types'; import './style.scss'; -/** - * This will be replaces in the future with a dynamic date range picker. - */ -const getDateRange = (): DateRange => { - return { - // Subtract 6 days from the current date. 7 days including the current day. - date_start: moment() - .subtract( 6, 'd' ) - .format( 'YYYY-MM-DD\\THH:mm:ss' ), - date_end: moment().format( 'YYYY-MM-DD\\THH:mm:ss' ), - }; -}; - const searchTermsForViewReportLink = { totalPaymentVolume: [ 'charge', @@ -44,7 +30,7 @@ const searchTermsForViewReportLink = { 'card_reader_fee', ], - charge: [ 'charge', 'payment' ], + charge: [ 'charge', 'payment', 'adjustment' ], refunds: [ 'refund', @@ -52,6 +38,8 @@ const searchTermsForViewReportLink = { 'payment_refund', 'payment_failure_refund', ], + + dispute: [ 'dispute', 'dispute_reversal' ], }; const getSearchParams = ( searchTerms: string[] ) => { @@ -64,11 +52,15 @@ const getSearchParams = ( searchTerms: string[] ) => { ); }; -const PaymentActivityData: React.FC = () => { - const { paymentActivityData, isLoading } = usePaymentActivityData( - getDateRange() - ); +interface Props { + paymentActivityData?: PaymentActivityData; + isLoading?: boolean; +} +const PaymentActivityDataComponent: React.FC< Props > = ( { + paymentActivityData, + isLoading, +} ) => { const totalPaymentVolume = paymentActivityData?.total_payment_volume ?? 0; const charges = paymentActivityData?.charges ?? 0; const fees = paymentActivityData?.fees ?? 0; @@ -121,11 +113,11 @@ const PaymentActivityData: React.FC = () => { path: '/payments/transactions', filter: 'advanced', 'date_between[0]': moment( - getDateRange().date_start + paymentActivityData?.date_start + ).format( 'YYYY-MM-DD' ), + 'date_between[1]': moment( + paymentActivityData?.date_end ).format( 'YYYY-MM-DD' ), - 'date_between[1]': moment( getDateRange().date_end ).format( - 'YYYY-MM-DD' - ), ...getSearchParams( searchTermsForViewReportLink.totalPaymentVolume ), @@ -163,10 +155,10 @@ const PaymentActivityData: React.FC = () => { path: '/payments/transactions', filter: 'advanced', 'date_between[0]': moment( - getDateRange().date_start + paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), 'date_between[1]': moment( - getDateRange().date_end + paymentActivityData?.date_end ).format( 'YYYY-MM-DD' ), ...getSearchParams( searchTermsForViewReportLink.charge @@ -185,10 +177,10 @@ const PaymentActivityData: React.FC = () => { path: '/payments/transactions', filter: 'advanced', 'date_between[0]': moment( - getDateRange().date_start + paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), 'date_between[1]': moment( - getDateRange().date_end + paymentActivityData?.date_end ).format( 'YYYY-MM-DD' ), ...getSearchParams( searchTermsForViewReportLink.refunds @@ -204,15 +196,17 @@ const PaymentActivityData: React.FC = () => { amount={ disputes } reportLink={ getAdminUrl( { page: 'wc-admin', - path: '/payments/disputes', + path: '/payments/transactions', filter: 'advanced', 'date_between[0]': moment( - getDateRange().date_start + paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), 'date_between[1]': moment( - getDateRange().date_end + paymentActivityData?.date_end ).format( 'YYYY-MM-DD' ), - status_is: 'needs_response', + ...getSearchParams( + searchTermsForViewReportLink.dispute + ), } ) } tracksSource="disputes" isLoading={ isLoading } @@ -248,4 +242,4 @@ const PaymentActivityData: React.FC = () => { ); }; -export default PaymentActivityData; +export default PaymentActivityDataComponent; diff --git a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap index 8ffa02adb61..2a55f39d20a 100644 --- a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap +++ b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap @@ -76,7 +76,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -136,7 +136,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -165,7 +165,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -194,7 +194,7 @@ exports[`PaymentActivity component should render 1`] = `

View report diff --git a/client/components/woopay/save-user/test/checkout-page-save-user.test.js b/client/components/woopay/save-user/test/checkout-page-save-user.test.js index c39d464a8fe..a43e95e4f9a 100644 --- a/client/components/woopay/save-user/test/checkout-page-save-user.test.js +++ b/client/components/woopay/save-user/test/checkout-page-save-user.test.js @@ -39,7 +39,6 @@ jest.mock( 'tracks', () => ( { events: { WOOPAY_EMAIL_CHECK: 'checkout_email_address_woopay_check', WOOPAY_OFFERED: 'checkout_woopay_save_my_info_offered', - WOOPAY_AUTO_REDIRECT: 'checkout_woopay_auto_redirect', WOOPAY_SKIPPED: 'woopay_skipped', WOOPAY_BUTTON_LOAD: 'woopay_button_load', WOOPAY_BUTTON_CLICK: 'woopay_button_click', diff --git a/client/data/payment-activity/test/hooks.test.ts b/client/data/payment-activity/test/hooks.test.ts index 03d1d2834d7..4cd6114f69a 100644 --- a/client/data/payment-activity/test/hooks.test.ts +++ b/client/data/payment-activity/test/hooks.test.ts @@ -36,6 +36,7 @@ describe( 'usePaymentActivityData', () => { const result = usePaymentActivityData( { date_start: '2021-01-01', date_end: '2021-01-31', + timezone: 'UTC', } ); expect( result ).toEqual( { diff --git a/client/data/payment-activity/test/resolver.test.ts b/client/data/payment-activity/test/resolver.test.ts index 8142b49b7c9..a9a93977180 100644 --- a/client/data/payment-activity/test/resolver.test.ts +++ b/client/data/payment-activity/test/resolver.test.ts @@ -15,12 +15,13 @@ import { getPaymentActivityData } from '../resolvers'; const query = { date_start: '2020-04-29T04:00:00', date_end: '2020-04-29T03:59:59', + timezone: '+2:30', }; describe( 'getPaymentActivityData resolver', () => { const successfulResponse: any = { amount: 3000 }; const expectedQueryString = - 'date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59'; + 'date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59&timezone=%2B2%3A30'; const errorResponse = new Error( 'Error retrieving payment activity data.' ); diff --git a/client/data/payment-activity/types.d.ts b/client/data/payment-activity/types.d.ts index ef388d2b899..76690dc459c 100644 --- a/client/data/payment-activity/types.d.ts +++ b/client/data/payment-activity/types.d.ts @@ -39,5 +39,5 @@ export interface PaymentActivityQuery { /** The date range end datetime used to calculate transaction data, e.g. 2024-04-29T16:19:29 */ date_end: string; /** The timezone used to calculate the transaction data date range, e.g. 'UTC' */ - timezone?: string; + timezone: string; } diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js index ef467116d79..f35cb3c4d14 100644 --- a/client/data/settings/actions.js +++ b/client/data/settings/actions.js @@ -28,12 +28,6 @@ export function updateIsCardPresentEligible( isEnabled ) { return updateSettingsValues( { is_card_present_eligible: isEnabled } ); } -export function updateIsClientSecretEncryptionEnabled( isEnabled ) { - return updateSettingsValues( { - is_client_secret_encryption_enabled: isEnabled, - } ); -} - export function updatePaymentRequestButtonType( type ) { return updateSettingsValues( { payment_request_button_type: type } ); } diff --git a/client/data/settings/hooks.js b/client/data/settings/hooks.js index 6930939a153..442d7c06d12 100644 --- a/client/data/settings/hooks.js +++ b/client/data/settings/hooks.js @@ -30,19 +30,6 @@ export const useCardPresentEligible = () => { return [ isCardPresentEligible, updateIsCardPresentEligible ]; }; -export const useClientSecretEncryption = () => { - const { updateIsClientSecretEncryptionEnabled } = useDispatch( STORE_NAME ); - - const isClientSecretEncryptionEnabled = useSelect( ( select ) => - select( STORE_NAME ).getIsClientSecretEncryptionEnabled() - ); - - return [ - isClientSecretEncryptionEnabled, - updateIsClientSecretEncryptionEnabled, - ]; -}; - export const useEnabledPaymentMethodIds = () => { const { updateEnabledPaymentMethodIds } = useDispatch( STORE_NAME ); diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js index a1a5191e932..fccbd5a166a 100644 --- a/client/data/settings/selectors.js +++ b/client/data/settings/selectors.js @@ -32,10 +32,6 @@ export const getIsWCPayEnabled = ( state ) => { return getSettings( state ).is_wcpay_enabled || false; }; -export const getIsClientSecretEncryptionEnabled = ( state ) => { - return getSettings( state ).is_client_secret_encryption_enabled || false; -}; - export const getEnabledPaymentMethodIds = ( state ) => { return getSettings( state ).enabled_payment_method_ids || EMPTY_ARR; }; diff --git a/client/data/settings/test/hooks.js b/client/data/settings/test/hooks.js index c9006b30064..3748b09bc29 100644 --- a/client/data/settings/test/hooks.js +++ b/client/data/settings/test/hooks.js @@ -18,7 +18,6 @@ import { useWooPayEnabledSettings, useWooPayCustomMessage, useWooPayStoreLogo, - useClientSecretEncryption, useGetDuplicatedPaymentMethodIds, } from '../hooks'; import { STORE_NAME } from '../../constants'; @@ -262,39 +261,6 @@ describe( 'Settings hooks tests', () => { } ); } ); - describe( 'useClientSecretEncryption()', () => { - test( 'returns and updates client secret encryption settings', () => { - const clientSecretEncryptionBeforeUpdate = false; - const clientSecretEncryptionAfterUpdate = true; - - actions = { - updateIsClientSecretEncryptionEnabled: jest.fn(), - }; - - selectors = { - getIsClientSecretEncryptionEnabled: jest.fn( - () => clientSecretEncryptionBeforeUpdate - ), - }; - - const [ - isClientEncryptionEnabled, - updateIsClientSecretEncryptionEnabled, - ] = useClientSecretEncryption(); - - updateIsClientSecretEncryptionEnabled( - clientSecretEncryptionAfterUpdate - ); - - expect( isClientEncryptionEnabled ).toEqual( - clientSecretEncryptionBeforeUpdate - ); - expect( - actions.updateIsClientSecretEncryptionEnabled - ).toHaveBeenCalledWith( clientSecretEncryptionAfterUpdate ); - } ); - } ); - describe( 'useWooPayEnabledSettings()', () => { test( 'returns woopay setting from selector', () => { actions = { diff --git a/client/data/settings/test/reducer.js b/client/data/settings/test/reducer.js index d323a8e4426..7cd4f4ef7c3 100644 --- a/client/data/settings/test/reducer.js +++ b/client/data/settings/test/reducer.js @@ -18,7 +18,6 @@ import { updateIsWooPayEnabled, updateWooPayCustomMessage, updateWooPayStoreLogo, - updateIsClientSecretEncryptionEnabled, } from '../actions'; describe( 'Settings reducer tests', () => { @@ -490,48 +489,4 @@ describe( 'Settings reducer tests', () => { } ); } ); } ); - - describe( 'SET_IS_CLIENT_SECRET_ENCRYPTION_ENABLED', () => { - test( 'toggle `data.is_client_secret_encryption_enabled`', () => { - const oldState = { - data: { - is_client_secret_encryption_enabled: false, - }, - }; - - const state = reducer( - oldState, - updateIsClientSecretEncryptionEnabled( true ) - ); - - expect( state.data.is_client_secret_encryption_enabled ).toEqual( - true - ); - } ); - - test( 'leaves other fields unchanged', () => { - const oldState = { - foo: 'bar', - data: { - is_client_secret_encryption_enabled: false, - baz: 'quux', - }, - savingError: {}, - }; - - const state = reducer( - oldState, - updateIsClientSecretEncryptionEnabled( true ) - ); - - expect( state ).toEqual( { - foo: 'bar', - data: { - is_client_secret_encryption_enabled: true, - baz: 'quux', - }, - savingError: null, - } ); - } ); - } ); } ); diff --git a/client/data/settings/test/selectors.js b/client/data/settings/test/selectors.js index 728c61c26a8..dc7cfaba6b6 100644 --- a/client/data/settings/test/selectors.js +++ b/client/data/settings/test/selectors.js @@ -19,7 +19,6 @@ import { getIsWooPayEnabled, getWooPayCustomMessage, getWooPayStoreLogo, - getIsClientSecretEncryptionEnabled, getDuplicatedPaymentMethodIds, } from '../selectors'; @@ -68,29 +67,6 @@ describe( 'Settings selectors tests', () => { } ); } ); - describe( 'getIsClientSecretEncryptionEnabled()', () => { - test( 'returns the value of state.settings.data.is_client_secret_encryption_enabled', () => { - const state = { - settings: { - data: { - is_client_secret_encryption_enabled: true, - }, - }, - }; - - expect( getIsClientSecretEncryptionEnabled( state ) ).toBeTruthy(); - } ); - - test.each( [ - [ undefined ], - [ {} ], - [ { settings: {} } ], - [ { settings: { data: {} } } ], - ] )( 'returns false if missing (tested state: %j)', ( state ) => { - expect( getIsClientSecretEncryptionEnabled( state ) ).toBeFalsy(); - } ); - } ); - describe( 'getEnabledPaymentMethodIds()', () => { test( 'returns the value of state.settings.data.enabled_payment_method_ids', () => { const state = { diff --git a/client/express-checkout/blocks/apple-pay-preview.js b/client/express-checkout/blocks/apple-pay-preview.js new file mode 100644 index 00000000000..9fc00562b1d --- /dev/null +++ b/client/express-checkout/blocks/apple-pay-preview.js @@ -0,0 +1,7 @@ +/* eslint-disable max-len */ +const applePayImage = + "data:image/svg+xml,%3Csvg width='264' height='48' viewBox='0 0 264 48' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='264' height='48' rx='3' fill='black'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.114 16.6407C125.682 15.93 126.067 14.9756 125.966 14C125.135 14.0415 124.121 14.549 123.533 15.2602C123.006 15.8693 122.539 16.8641 122.661 17.7983C123.594 17.8797 124.526 17.3317 125.114 16.6407Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M125.955 17.982C124.601 17.9011 123.448 18.7518 122.801 18.7518C122.154 18.7518 121.163 18.0224 120.092 18.0421C118.696 18.0629 117.402 18.8524 116.694 20.1079C115.238 22.6196 116.31 26.3453 117.726 28.3909C118.414 29.4028 119.242 30.5174 120.334 30.4769C121.366 30.4365 121.77 29.8087 123.024 29.8087C124.277 29.8087 124.641 30.4769 125.733 30.4567C126.865 30.4365 127.573 29.4443 128.261 28.4313C129.049 27.2779 129.373 26.1639 129.393 26.1027C129.373 26.0825 127.209 25.2515 127.189 22.7606C127.169 20.6751 128.888 19.6834 128.969 19.6217C127.998 18.1847 126.481 18.0224 125.955 17.982Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M136.131 23.1804H138.834C140.886 23.1804 142.053 22.0752 142.053 20.1592C142.053 18.2432 140.886 17.1478 138.845 17.1478H136.131V23.1804ZM139.466 15.1582C142.411 15.1582 144.461 17.1903 144.461 20.1483C144.461 23.1172 142.369 25.1596 139.392 25.1596H136.131V30.3498H133.775V15.1582H139.466Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M152.198 26.224V25.3712L149.579 25.5397C148.106 25.6341 147.339 26.182 147.339 27.14C147.339 28.0664 148.138 28.6667 149.39 28.6667C150.988 28.6667 152.198 27.6449 152.198 26.224ZM145.046 27.2032C145.046 25.2551 146.529 24.1395 149.263 23.971L152.198 23.7922V22.9498C152.198 21.7181 151.388 21.0442 149.947 21.0442C148.758 21.0442 147.896 21.6548 147.717 22.5916H145.592C145.656 20.6232 147.507 19.1914 150.01 19.1914C152.703 19.1914 154.459 20.602 154.459 22.7917V30.351H152.282V28.5298H152.229C151.609 29.719 150.241 30.4666 148.758 30.4666C146.571 30.4666 145.046 29.1612 145.046 27.2032Z' fill='white'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M156.461 34.4145V32.5934C156.608 32.6141 156.965 32.6354 157.155 32.6354C158.196 32.6354 158.785 32.1932 159.142 31.0564L159.353 30.3824L155.366 19.3281H157.827L160.604 28.298H160.657L163.434 19.3281H165.832L161.698 30.9402C160.752 33.6038 159.668 34.4778 157.376 34.4778C157.197 34.4778 156.618 34.4565 156.461 34.4145Z' fill='white'/%3E%3C/svg%3E%0A"; + +const ApplePayPreview = () => ; + +export default ApplePayPreview; diff --git a/client/express-checkout/blocks/express-checkout.js b/client/express-checkout/blocks/express-checkout.js new file mode 100644 index 00000000000..5b4c47658bd --- /dev/null +++ b/client/express-checkout/blocks/express-checkout.js @@ -0,0 +1,36 @@ +/* global wcpayExpressCheckoutParams */ + +/** + * External dependencies + */ +import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; + +/** + * ExpressCheckout express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +export const ExpressCheckout = ( props ) => { + const { stripe } = props; + + const options = { + mode: 'payment', + amount: 1099, + currency: 'usd', + }; + + const buttonOptions = { + buttonType: { + googlePay: wcpayExpressCheckoutParams.button.type, + applePay: wcpayExpressCheckoutParams.button.type, + }, + }; + + return ( + + + + ); +}; diff --git a/client/express-checkout/blocks/index.js b/client/express-checkout/blocks/index.js new file mode 100644 index 00000000000..17b2c1221dd --- /dev/null +++ b/client/express-checkout/blocks/index.js @@ -0,0 +1,32 @@ +/* global wcpayConfig, wcpayExpressCheckoutParams */ + +/** + * Internal dependencies + */ +import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from '../../checkout/constants'; +import { ExpressCheckout } from './express-checkout'; +import { getConfig } from '../../utils/checkout'; +import ApplePayPreview from './apple-pay-preview'; + +const expressCheckoutElementPaymentMethod = ( api ) => ( { + name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, + content: , + edit: , + paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, + supports: { + features: getConfig( 'features' ), + }, + canMakePayment: () => { + if ( typeof wcpayExpressCheckoutParams === 'undefined' ) { + return false; + } + + if ( typeof wcpayConfig !== 'undefined' ) { + return wcpayConfig.isExpressCheckoutElementEnabled; + } + + return false; + }, +} ); + +export default expressCheckoutElementPaymentMethod; diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js new file mode 100644 index 00000000000..3a9c2e0c37d --- /dev/null +++ b/client/express-checkout/index.js @@ -0,0 +1,383 @@ +/* global jQuery, wcpayExpressCheckoutParams */ + +/** + * Internal dependencies + */ +import WCPayAPI from '../checkout/api'; +import '../checkout/express-checkout-buttons.scss'; + +jQuery( ( $ ) => { + // Don't load if blocks checkout is being loaded. + if ( + wcpayExpressCheckoutParams.has_block && + ! wcpayExpressCheckoutParams.is_pay_for_order + ) { + return; + } + + const publishableKey = wcpayExpressCheckoutParams.stripe.publishableKey; + + if ( ! publishableKey ) { + // If no configuration is present, probably this is not the checkout page. + return; + } + + const api = new WCPayAPI( + { + publishableKey, + accountId: wcpayExpressCheckoutParams.stripe.accountId, + locale: wcpayExpressCheckoutParams.stripe.locale, + }, + // A promise-based interface to jQuery.post. + ( url, args ) => { + return new Promise( ( resolve, reject ) => { + jQuery.post( url, args ).then( resolve ).fail( reject ); + } ); + } + ); + + /** + * Object to handle Stripe payment forms. + */ + const wcpayECE = { + /** + * Whether the payment was aborted by the customer. + */ + paymentAborted: false, + + getAttributes: function () { + const select = $( '.variations_form' ).find( '.variations select' ); + const data = {}; + let count = 0; + let chosen = 0; + + select.each( function () { + const attributeName = + $( this ).data( 'attribute_name' ) || + $( this ).attr( 'name' ); + const value = $( this ).val() || ''; + + if ( value.length > 0 ) { + chosen++; + } + + count++; + data[ attributeName ] = value; + } ); + + return { + count: count, + chosenCount: chosen, + data: data, + }; + }, + + /** + * Abort payment and display error messages. + * + * @param {PaymentResponse} payment Payment response instance. + * @param {string} message Error message to display. + */ + abortPayment: ( payment, message ) => { + payment.complete( 'fail' ); + + $( '.woocommerce-error' ).remove(); + + const $container = $( '.woocommerce-notices-wrapper' ).first(); + + if ( $container.length ) { + $container.append( + $( '
' ).text( message ) + ); + + $( 'html, body' ).animate( + { + scrollTop: $container + .find( '.woocommerce-error' ) + .offset().top, + }, + 600 + ); + } + }, + + /** + * Complete payment. + * + * @param {string} url Order thank you page URL. + */ + completePayment: ( url ) => { + wcpayECE.block(); + window.location = url; + }, + + block: () => { + $.blockUI( { + message: null, + overlayCSS: { + background: '#fff', + opacity: 0.6, + }, + } ); + }, + + /** + * Adds the item to the cart and return cart details. + * + * @return {Promise} Promise for the request to the server. + */ + addToCart: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const data = { + product_id: productId, + qty: $( '.quantity .qty' ).val(), + attributes: $( '.variations_form' ).length + ? wcpayECE.getAttributes().data + : [], + }; + + // Add extension data to the POST body + const formData = $( 'form.cart' ).serializeArray(); + $.each( formData, ( i, field ) => { + if ( /^(addon-|wc_)/.test( field.name ) ) { + if ( /\[\]$/.test( field.name ) ) { + const fieldName = field.name.substring( + 0, + field.name.length - 2 + ); + if ( data[ fieldName ] ) { + data[ fieldName ].push( field.value ); + } else { + data[ fieldName ] = [ field.value ]; + } + } else { + data[ field.name ] = field.value; + } + } + } ); + + return api.paymentRequestAddToCart( data ); + }, + + /** + * Starts the Express Checkout Element + * + * @param {Object} options ECE options. + */ + startExpressCheckoutElement: ( options ) => { + const elements = api.getStripe().elements( { + mode: options?.mode ?? 'payment', + amount: options?.total, + currency: options?.currency, + } ); + + const eceButton = wcpayECE.createButton( elements, { + buttonType: { + googlePay: wcpayExpressCheckoutParams.button.type, + applePay: wcpayExpressCheckoutParams.button.type, + }, + } ); + + wcpayECE.showButton( eceButton ); + + wcpayECE.attachButtonEventListeners( eceButton ); + + eceButton.on( 'click', function ( event ) { + const clickOptions = { + business: { + name: 'Mikes Bikes', + }, + lineItems: [ + { name: 'Bike', amount: 200 }, + { name: 'Helmet', amount: 300 }, + ], + shippingAddressRequired: true, + shippingRates: [ + { + id: '1', + amount: 500, + displayName: 'Standard Shipping', + }, + { + id: '2', + amount: 1000, + displayName: 'Expedited Shipping', + }, + ], + }; + event.resolve( clickOptions ); + } ); + + eceButton.on( 'cancel', () => { + wcpayECE.paymentAborted = true; + } ); + }, + + getSelectedProductData: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const addons = + $( '#product-addons-total' ).data( 'price_data' ) || []; + const addonValue = addons.reduce( + ( sum, addon ) => sum + addon.cost, + 0 + ); + + // WC Deposits Support. + const depositObject = {}; + if ( $( 'input[name=wc_deposit_option]' ).length ) { + depositObject.wc_deposit_option = $( + 'input[name=wc_deposit_option]:checked' + ).val(); + } + if ( $( 'input[name=wc_deposit_payment_plan]' ).length ) { + depositObject.wc_deposit_payment_plan = $( + 'input[name=wc_deposit_payment_plan]:checked' + ).val(); + } + + const data = { + product_id: productId, + qty: $( '.quantity .qty' ).val(), + attributes: $( '.variations_form' ).length + ? wcpayECE.getAttributes().data + : [], + addon_value: addonValue, + ...depositObject, + }; + + return api.paymentRequestGetSelectedProductData( data ); + }, + + /** + * Creates Stripe Express Checkout Element. + * + * @param {Object} elements Stripe elements instance. + * @param {Object} options Options for creating the Express Checkout Element. + * + * @return {Object} Stripe Express Checkout Element. + */ + createButton: ( elements, options ) => { + return elements.create( 'expressCheckout', options ); + }, + + getElements: () => { + return $( + '.wcpay-payment-request-wrapper,#wcpay-express-checkout-button-separator' + ); + }, + + hide: () => { + wcpayECE.getElements().hide(); + }, + + show: () => { + wcpayECE.getElements().show(); + }, + + showButton: ( eceButton ) => { + if ( $( '#wcpay-express-checkout-element' ).length ) { + wcpayECE.show(); + eceButton.mount( '#wcpay-express-checkout-element' ); + } + }, + + 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 ( + $( '#wcpay-express-checkout-button' ).data( + 'blockUI.isBlocked' + ) + ) { + return; + } + + $( '#wcpay-express-checkout-button' ).block( { message: null } ); + }, + + unblockButton: () => { + wcpayECE.show(); + $( '#wcpay-express-checkout-button' ).unblock(); + }, + + /** + * Initialize event handlers and UI state + */ + init: () => { + if ( wcpayExpressCheckoutParams.is_pay_for_order ) { + if ( ! window.wcpayECEPayForOrderParams ) { + return; + } + + wcpayECE.startExpressCheckoutElement(); + } else if ( wcpayExpressCheckoutParams.is_product_page ) { + wcpayECE.startExpressCheckoutElement( { + mode: 'payment', + total: wcpayExpressCheckoutParams.product.total.amount, + currency: 'usd', + requestShipping: + wcpayExpressCheckoutParams.product.needs_shipping, + displayItems: + wcpayExpressCheckoutParams.product.displayItems, + } ); + } else { + // If this is the cart or checkout page, we need to request the + // cart details. + api.paymentRequestGetCartDetails().then( ( cart ) => { + wcpayECE.startExpressCheckoutElement( { + mode: 'payment', + total: 1000, + currency: 'usd', + requestShipping: cart.needs_shipping, + displayItems: cart.displayItems, + } ); + } ); + } + + // After initializing a new element, we need to reset the paymentAborted flag. + wcpayECE.paymentAborted = false; + }, + }; + + // We don't need to initialize ECE on the checkout page now because it will be initialized by updated_checkout event. + if ( + ! wcpayExpressCheckoutParams.is_checkout_page || + wcpayExpressCheckoutParams.is_pay_for_order + ) { + wcpayECE.init(); + } + + // We need to refresh ECE data when total is updated. + $( document.body ).on( 'updated_cart_totals', () => { + wcpayECE.init(); + } ); + + // We need to refresh ECE data when total is updated. + $( document.body ).on( 'updated_checkout', () => { + wcpayECE.init(); + } ); +} ); diff --git a/client/globals.d.ts b/client/globals.d.ts index 5d215c903c8..c64240cd04f 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -41,7 +41,6 @@ declare global { minimum_manual_deposit_amounts: Record< string, number >; minimum_scheduled_deposit_amounts: Record< string, number >; }; - depositsStatus?: string; currentDeadline?: bigint; detailsSubmitted?: boolean; pastDue?: boolean; @@ -169,6 +168,11 @@ declare global { woocommerce_default_country: string; }; }; + siteVisibilitySettings: { + woocommerce_share_key: string; + woocommerce_coming_soon: string; + woocommerce_private_link: string; + }; }; adminUrl: string; countries: Record< string, string >; diff --git a/client/multi-currency/single-currency-settings/currency-preview.js b/client/multi-currency/single-currency-settings/currency-preview.js index a6ba2a890b3..952bc537363 100644 --- a/client/multi-currency/single-currency-settings/currency-preview.js +++ b/client/multi-currency/single-currency-settings/currency-preview.js @@ -36,16 +36,11 @@ const CurrencyPreview = ( { ? charmed : charmed * 100, targetCurrency.code, - storeCurrency.code + null, + true ); }, - [ - charmValue, - currencyRate, - roundingValue, - targetCurrency, - storeCurrency, - ] + [ charmValue, currencyRate, roundingValue, targetCurrency ] ); useEffect( () => { diff --git a/client/onboarding/index.tsx b/client/onboarding/index.tsx index 4100d0426d9..375c0bbd4e2 100644 --- a/client/onboarding/index.tsx +++ b/client/onboarding/index.tsx @@ -46,13 +46,27 @@ const OnboardingStepper = () => { ); }; +const getComingSoonShareKey = () => { + const { + woocommerce_share_key: shareKey, + woocommerce_coming_soon: comingSoon, + woocommerce_private_link: privateLink, + } = wcSettings?.admin?.siteVisibilitySettings || {}; + + if ( comingSoon !== 'yes' || privateLink === 'no' ) { + return ''; + } + + return shareKey ? '?woo-share=' + shareKey : ''; +}; + const initialData = { business_name: wcSettings?.siteTitle, mcc: getMccFromIndustry(), url: location.hostname === 'localhost' ? 'https://wcpay.test' - : wcSettings?.homeUrl, + : wcSettings?.homeUrl + getComingSoonShareKey(), country: wcpaySettings?.connect?.country, }; diff --git a/client/overview/index.js b/client/overview/index.js index 03d5816b879..669055b1813 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -66,7 +66,7 @@ const OverviewSandboxModeNotice = ( { ctaAction = () => {} } ) => { /* translators: %1$s: WooPayments */ __( // eslint-disable-next-line max-len - '{{strong}}%1$s is in sandbox mode.{{/strong}} To accept real transactions, {{switchToLiveLink}}set up a live %1$s account{{/switchToLiveLink}}.{{learnMoreIcon/}}', + '{{strong}}%1$s is in sandbox mode.{{/strong}} To accept real transactions, {{switchToLiveLink}}set up a live %1$s account.{{/switchToLiveLink}} {{learnMoreIcon/}}', 'woocommerce-payments' ), 'WooPayments' @@ -80,7 +80,7 @@ const OverviewSandboxModeNotice = ( { ctaAction = () => {} } ) => { 'Learn more about sandbox mode', 'woocommerce-payments' ) } - maxWidth={ '315px' } + maxWidth={ '250px' } content={ <> { interpolateComponents( { @@ -88,13 +88,12 @@ const OverviewSandboxModeNotice = ( { ctaAction = () => {} } ) => { /* translators: %1$s: WooPayments */ __( // eslint-disable-next-line max-len - 'In sandbox mode, personal/business verifications and checkout payments are simulated. Find out what works best for you by {{strong}}testing all the %1$s options and flows.{{/strong}} {{learnMoreLink}}Learn more{{/learnMoreLink}}', + 'Sandbox mode gives you access to all %1$s features while checkout transactions are simulated. {{learnMoreLink}}Learn more{{/learnMoreLink}}', 'woocommerce-payments' ), 'WooPayments' ), components: { - strong: , learnMoreLink: ( // eslint-disable-next-line jsx-a11y/anchor-has-content ( { return false; } + if ( + typeof wcpayConfig !== 'undefined' && + wcpayConfig.isExpressCheckoutElementEnabled + ) { + return false; + } + return api.loadStripe( true ).then( ( stripe ) => { // Create a payment request and check if we can make a payment to determine whether to // show the Payment Request Button or not. This is necessary because a browser might be diff --git a/client/product-details/index.js b/client/product-details/index.js index 0591b84e794..93358a26f3b 100644 --- a/client/product-details/index.js +++ b/client/product-details/index.js @@ -6,7 +6,7 @@ */ import { initializeBnplSiteMessaging } from './bnpl-site-messaging'; import request from 'wcpay/checkout/utils/request'; -import { buildAjaxURL } from 'wcpay/payment-request/utils'; +import { buildAjaxURL } from 'wcpay/utils/express-checkout'; jQuery( async function ( $ ) { /** diff --git a/client/settings/advanced-settings/client-secret-encryption-toggle.js b/client/settings/advanced-settings/client-secret-encryption-toggle.js deleted file mode 100644 index 3d754c8f33e..00000000000 --- a/client/settings/advanced-settings/client-secret-encryption-toggle.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * External dependencies - */ -import { CheckboxControl } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { useEffect, useRef } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { useClientSecretEncryption } from 'wcpay/data'; - -const ClientSecretEncryptionToggle = () => { - const [ - isClientSecretEncryptionEnabled, - updateIsClientSecretEncryptionEnabled, - ] = useClientSecretEncryption(); - - const headingRef = useRef( null ); - - useEffect( () => { - if ( ! headingRef.current ) return; - - headingRef.current.focus(); - }, [] ); - - const handleClientSecretEncryptionStatusChange = ( value ) => { - updateIsClientSecretEncryptionEnabled( value ); - }; - - return ( - - ); -}; - -export default ClientSecretEncryptionToggle; diff --git a/client/settings/advanced-settings/index.js b/client/settings/advanced-settings/index.js index d30759b02a6..55480311cbe 100644 --- a/client/settings/advanced-settings/index.js +++ b/client/settings/advanced-settings/index.js @@ -12,7 +12,6 @@ import MultiCurrencyToggle from './multi-currency-toggle'; import WCPaySubscriptionsToggle from './wcpay-subscriptions-toggle'; import './style.scss'; import CardBody from '../card-body'; -import ClientSecretEncryptionToggle from './client-secret-encryption-toggle'; import StripeBillingSection from './stripe-billing-section'; const AdvancedSettings = () => { @@ -21,9 +20,6 @@ const AdvancedSettings = () => { - { wcpaySettings.isClientEncryptionEligible && ( - - ) } { wcpaySettings.isSubscriptionsActive && wcpaySettings.isStripeBillingEligible ? ( diff --git a/client/settings/advanced-settings/test/client-secret-encryption-toggle.test.js b/client/settings/advanced-settings/test/client-secret-encryption-toggle.test.js deleted file mode 100644 index cb580fa738b..00000000000 --- a/client/settings/advanced-settings/test/client-secret-encryption-toggle.test.js +++ /dev/null @@ -1,53 +0,0 @@ -/** @format **/ - -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; - -/** - * Internal dependencies - */ -import { useClientSecretEncryption } from 'wcpay/data'; -import ClientSecretEncryptionToggle from '../client-secret-encryption-toggle'; - -jest.mock( '../../../data', () => ( { - useClientSecretEncryption: jest.fn().mockReturnValue( [ true, jest.fn() ] ), -} ) ); - -describe( 'ClientSecretEncryptionToggle', () => { - afterEach( () => { - jest.clearAllMocks(); - } ); - - it( 'renders the component', () => { - render( ); - - expect( - screen.queryByLabelText( 'Enable Public Key Encryption' ) - ).toBeInTheDocument(); - } ); - - it.each( [ [ true ], [ false ] ] )( - 'updates client secret encryption enabled state to %s when toggling checkbox', - ( isEnabled ) => { - const updateIsClientSecretEncryptionEnabledMock = jest.fn(); - useClientSecretEncryption.mockReturnValue( [ - isEnabled, - updateIsClientSecretEncryptionEnabledMock, - ] ); - - render( ); - - const enableClientSecretEncryptionCheckbox = screen.getByLabelText( - 'Enable Public Key Encryption' - ); - - userEvent.click( enableClientSecretEncryptionCheckbox ); - expect( - updateIsClientSecretEncryptionEnabledMock - ).toHaveBeenCalledWith( ! isEnabled ); - } - ); -} ); diff --git a/client/settings/advanced-settings/test/index.test.js b/client/settings/advanced-settings/test/index.test.js index 517f237969e..1f15cec9c04 100644 --- a/client/settings/advanced-settings/test/index.test.js +++ b/client/settings/advanced-settings/test/index.test.js @@ -14,7 +14,6 @@ import { useWCPaySubscriptions, useDevMode, useDebugLog, - useClientSecretEncryption, } from 'wcpay/data'; jest.mock( '../../../data', () => ( { @@ -23,7 +22,6 @@ jest.mock( '../../../data', () => ( { useWCPaySubscriptions: jest.fn(), useDevMode: jest.fn(), useDebugLog: jest.fn(), - useClientSecretEncryption: jest.fn(), } ) ); describe( 'AdvancedSettings', () => { @@ -32,31 +30,10 @@ describe( 'AdvancedSettings', () => { useWCPaySubscriptions.mockReturnValue( [ false, jest.fn() ] ); useDevMode.mockReturnValue( false ); useDebugLog.mockReturnValue( [ false, jest.fn() ] ); - useClientSecretEncryption.mockReturnValue( [ false, jest.fn() ] ); } ); test( 'toggles the advanced settings section', () => { - global.wcpaySettings = { - isClientEncryptionEligible: true, - }; - render( ); - // The advanced settings section is expanded by default. - expect( - screen.queryByText( 'Enable Public Key Encryption' ) - ).toBeInTheDocument(); - expect( screen.queryByText( 'Debug mode' ) ).toBeInTheDocument(); - } ); - test( 'hides the client encryption toggle when not eligible', () => { - global.wcpaySettings = { - isClientEncryptionEligible: false, - }; - - render( ); - - expect( - screen.queryByText( 'Enable Public Key Encryption' ) - ).not.toBeInTheDocument(); expect( screen.queryByText( 'Debug mode' ) ).toBeInTheDocument(); } ); } ); diff --git a/client/settings/express-checkout-settings/general-payment-request-button-settings.js b/client/settings/express-checkout-settings/general-payment-request-button-settings.js index 65fffe941db..492731eb278 100644 --- a/client/settings/express-checkout-settings/general-payment-request-button-settings.js +++ b/client/settings/express-checkout-settings/general-payment-request-button-settings.js @@ -16,7 +16,7 @@ import CardBody from '../card-body'; import PaymentRequestButtonPreview from './payment-request-button-preview'; import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; import interpolateComponents from '@automattic/interpolate-components'; -import { getPaymentRequestData } from '../../payment-request/utils'; +import { getPaymentRequestData } from 'utils/express-checkout'; import WCPaySettingsContext from '../wcpay-settings-context'; import { usePaymentRequestButtonType, diff --git a/client/settings/express-checkout-settings/index.scss b/client/settings/express-checkout-settings/index.scss index 2741bdf7fe7..d3e4302e14c 100644 --- a/client/settings/express-checkout-settings/index.scss +++ b/client/settings/express-checkout-settings/index.scss @@ -292,12 +292,6 @@ } } -.express-checkout__notice { - margin-left: 0; - margin-right: 0; - border: 0; -} - .payment-method-settings { &__option-help-text { color: #757575; @@ -334,10 +328,12 @@ } } - &[data-theme='light'], - &[data-theme='light-outline'] { + &[data-theme='light'] { background: #2b2b2b; } + &[data-theme='light-outline'] { + background: #f0f0f0; + } } } diff --git a/client/settings/express-checkout-settings/payment-request-button-preview.js b/client/settings/express-checkout-settings/payment-request-button-preview.js index 817ca27a4de..d3a922b116d 100644 --- a/client/settings/express-checkout-settings/payment-request-button-preview.js +++ b/client/settings/express-checkout-settings/payment-request-button-preview.js @@ -13,7 +13,7 @@ import { /** * Internal dependencies */ -import { shouldUseGooglePayBrand } from 'payment-request/utils'; +import { shouldUseGooglePayBrand } from 'utils/express-checkout'; import InlineNotice from 'components/inline-notice'; import { WoopayExpressCheckoutButton } from 'wcpay/checkout/woopay/express-button/woopay-express-checkout-button'; import { diff --git a/client/settings/express-checkout-settings/test/index.js b/client/settings/express-checkout-settings/test/index.js index d814a7e358f..0fad045e17e 100644 --- a/client/settings/express-checkout-settings/test/index.js +++ b/client/settings/express-checkout-settings/test/index.js @@ -49,7 +49,7 @@ jest.mock( '@stripe/stripe-js', () => ( { loadStripe: jest.fn().mockReturnValue( null ), } ) ); -jest.mock( 'payment-request/utils', () => ( { +jest.mock( 'utils/express-checkout', () => ( { getPaymentRequestData: jest.fn().mockReturnValue( { publishableKey: '123', accountId: '0001', diff --git a/client/settings/express-checkout-settings/test/payment-request-button-preview.test.js b/client/settings/express-checkout-settings/test/payment-request-button-preview.test.js index 58e7db4fe3c..de745d6b8bb 100644 --- a/client/settings/express-checkout-settings/test/payment-request-button-preview.test.js +++ b/client/settings/express-checkout-settings/test/payment-request-button-preview.test.js @@ -11,14 +11,14 @@ import { useStripe } from '@stripe/react-stripe-js'; * Internal dependencies */ import PaymentRequestButtonPreview from '../payment-request-button-preview'; -import { shouldUseGooglePayBrand } from 'payment-request/utils'; +import { shouldUseGooglePayBrand } from 'utils/express-checkout'; jest.mock( '@wordpress/a11y', () => ( { ...jest.requireActual( '@wordpress/a11y' ), speak: jest.fn(), } ) ); -jest.mock( 'payment-request/utils', () => ( { +jest.mock( 'utils/express-checkout', () => ( { shouldUseGooglePayBrand: jest.fn(), } ) ); diff --git a/client/settings/express-checkout-settings/test/payment-request-settings.test.js b/client/settings/express-checkout-settings/test/payment-request-settings.test.js index c4857b2cba3..164eeff6c67 100644 --- a/client/settings/express-checkout-settings/test/payment-request-settings.test.js +++ b/client/settings/express-checkout-settings/test/payment-request-settings.test.js @@ -35,7 +35,7 @@ jest.mock( '../../../data', () => ( { jest.mock( '../payment-request-button-preview' ); PaymentRequestButtonPreview.mockImplementation( () => '<>' ); -jest.mock( 'payment-request/utils', () => ( { +jest.mock( 'utils/express-checkout', () => ( { getPaymentRequestData: jest.fn().mockReturnValue( { publishableKey: '123', accountId: '0001', @@ -264,7 +264,7 @@ describe( 'PaymentRequestSettings', () => { expect( screen.queryByText( - 'One or more of your extensions alters checkout fields. This might cause issues with this payment method.' + 'Your custom checkout fields may not be compatible with these payment methods.' ) ).toBeInTheDocument(); } ); @@ -276,7 +276,7 @@ describe( 'PaymentRequestSettings', () => { expect( screen.queryByText( - 'One or more of your extensions alters checkout fields. This might cause issues with this payment method.' + 'Your custom checkout fields may not be compatible with these payment methods.' ) ).not.toBeInTheDocument(); } ); diff --git a/client/settings/express-checkout/style.scss b/client/settings/express-checkout/style.scss index 65d43ab7bd9..e4bc54d4b17 100644 --- a/client/settings/express-checkout/style.scss +++ b/client/settings/express-checkout/style.scss @@ -9,12 +9,6 @@ padding: 24px; background: #fff; - .gridicons-notice-outline { - fill: #f0b849; - margin-bottom: -5px; - margin-right: 16px; - } - &__label-container { display: flex; flex-wrap: wrap; diff --git a/client/settings/express-checkout/test/index.test.js b/client/settings/express-checkout/test/index.test.js index 83c40f7487f..ca6bb88ecb3 100644 --- a/client/settings/express-checkout/test/index.test.js +++ b/client/settings/express-checkout/test/index.test.js @@ -236,7 +236,7 @@ describe( 'ExpressCheckout', () => { expect( screen.queryByText( - 'One or more of your extensions alters checkout fields. This might cause issues with this payment method.' + 'Your custom checkout fields may not be compatible with these payment methods.' ) ).toBeInTheDocument(); } ); diff --git a/client/settings/settings-warnings/incompatibility-notice.js b/client/settings/settings-warnings/incompatibility-notice.js index f990abdec37..52d9d07f098 100644 --- a/client/settings/settings-warnings/incompatibility-notice.js +++ b/client/settings/settings-warnings/incompatibility-notice.js @@ -7,48 +7,30 @@ import interpolateComponents from '@automattic/interpolate-components'; /** * Internal dependencies */ -import { Notice } from '@wordpress/components'; -import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; import './style.scss'; +import InlineNotice from 'wcpay/components/inline-notice'; const IncompatibilityNotice = ( { message, learnMoreLinkHref } ) => ( - - - - - - { message } -
- { interpolateComponents( { - mixedString: __( - '{{learnMoreLink}}Learn More{{/learnMoreLink}}', - 'woocommerce-payments' + + { message } +
+ { interpolateComponents( { + mixedString: __( + '{{learnMoreLink}}Learn More{{/learnMoreLink}}', + 'woocommerce-payments' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + ), - components: { - learnMoreLink: ( - // eslint-disable-next-line jsx-a11y/anchor-has-content - - ), - }, - } ) } -
-
+ }, + } ) } + ); export const WooPayIncompatibilityNotice = () => ( @@ -64,7 +46,7 @@ export const WooPayIncompatibilityNotice = () => ( export const ExpressCheckoutIncompatibilityNotice = () => ( { + $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..c068fa9f789 --- /dev/null +++ b/client/tokenized-payment-request/cart-api.js @@ -0,0 +1,214 @@ +/* global jQuery */ + +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { applyFilters } from '@wordpress/hooks'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * 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 = {}; + + /** + * Makes a request to the API. + * + * @param {Object} options The options to pass to `apiFetch`. + * @return {Promise} Result from `apiFetch`. + */ + async _request( options ) { + return await apiFetch( { + ...options, + 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( + 'checkout' + ).currency_code.toUpperCase(), + } ), + headers: { + ...this.cartRequestHeaders, + ...options.headers, + }, + } ); + } + + /** + * 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 this._request( { + method: 'POST', + path: '/wc/store/v1/checkout', + credentials: 'omit', + headers: { + 'X-WooPayments-Express-Payment-Request': true, + // either using the global nonce or the one cached from the anonymous cart (with the anonymous cart one taking precedence). + '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 this._request( { + method: 'GET', + path: '/wc/store/v1/cart', + } ); + } + + /** + * 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 this._request( { + 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' ), + // this header will be overwritten by a filter in the backend to overcome nonce overwrites in this middleware: + // https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/assets/js/middleware/store-api-nonce.js + 'X-WooPayments-Store-Api-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 this._request( { + method: 'POST', + path: '/wc/store/v1/cart/update-customer', + credentials: 'omit', + headers: { + 'X-WooPayments-Express-Payment-Request': true, + // either using the global nonce or the one cached from the anonymous cart (with the anonymous cart one taking precedence). + '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 this._request( { + method: 'POST', + path: '/wc/store/v1/cart/select-shipping-rate', + credentials: 'omit', + 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 this._request( { + method: 'POST', + path: '/wc/store/v1/cart/add-item', + credentials: 'omit', + 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 this._request( { + method: 'GET', + path: '/wc/store/v1/cart', + credentials: 'omit', + } ); + + const removeItemsPromises = cartData.items.map( ( item ) => { + return this._request( { + method: 'POST', + path: '/wc/store/v1/cart/remove-item', + credentials: 'omit', + 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..108aa1afd1e --- /dev/null +++ b/client/tokenized-payment-request/test/cart-api.test.js @@ -0,0 +1,126 @@ +/** + * 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'; +global.wcpayPaymentRequestParams.checkout = {}; +global.wcpayPaymentRequestParams.checkout.currency_code = 'USD'; + +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: expect.stringContaining( '/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: expect.stringContaining( + '/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: expect.stringContaining( + '/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: expect.stringContaining( + '/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/client/tracks/event.d.ts b/client/tracks/event.d.ts index 2bdd756102f..6bc89e98839 100644 --- a/client/tracks/event.d.ts +++ b/client/tracks/event.d.ts @@ -96,7 +96,6 @@ export type Event = | 'checkout_save_my_info_privacy_policy_click' | 'checkout_save_my_info_tooltip_click' | 'checkout_save_my_info_tooltip_learn_more_click' - | 'checkout_woopay_auto_redirect' | 'woopay_skipped' | 'woopay_button_load' | 'woopay_button_click' diff --git a/client/tracks/index.ts b/client/tracks/index.ts index 7b07931ee5b..d3f14dd57b3 100644 --- a/client/tracks/index.ts +++ b/client/tracks/index.ts @@ -8,7 +8,7 @@ import domReady from '@wordpress/dom-ready'; */ import { Event } from './event'; import { getConfig } from 'wcpay/utils/checkout'; -import { getPaymentRequestData } from 'wcpay/payment-request/utils'; +import { getPaymentRequestData } from 'wcpay/utils/express-checkout'; /** * Checks if site tracking is enabled. diff --git a/client/transactions/list/index.tsx b/client/transactions/list/index.tsx index a4b2108f308..d1ff4d2f4f2 100644 --- a/client/transactions/list/index.tsx +++ b/client/transactions/list/index.tsx @@ -103,10 +103,11 @@ const getPaymentSourceDetails = ( txn: Transaction ) => { switch ( txn.source ) { case 'giropay': - return { txn.source_identifier }; + return   { txn.source_identifier }; case 'p24': return ( +    { p24BankList[ txn.source_identifier ] ?? '' } ); diff --git a/client/utils/currency/index.js b/client/utils/currency/index.js index 85e94d32a61..e32b423fc37 100644 --- a/client/utils/currency/index.js +++ b/client/utils/currency/index.js @@ -68,6 +68,48 @@ export const getCurrency = ( currencyCode, baseCurrencyCode = null ) => { }; /* eslint-enable valid-jsdoc */ +/** + * Gets wc-admin currency for the given currency code. Unlike `getCurrency`, this function + * will return the currency based only on the currency itself and locale. This means that the + * currency object will not be modified based on the store's country settings, + * or on another given currency. + * + * @param {string} currencyCode Currency code + * @return {Object} Currency object. + */ +export const getCurrencyByLocale = ( currencyCode ) => { + const code = currencyCode.toUpperCase(); + const { + currencyData, + connect: { country = 'US' }, + } = wcpaySettings; + + // If store's country currency matches the provided currency code, return store's country currency. + if ( currencyData[ country ]?.code === code ) { + return Currency( currencyData[ country ] ); + } + + const currency = find( currencyData, { code } ); + + if ( currency ) { + const { defaultLocale = {} } = currency; + + if ( + defaultLocale.hasOwnProperty( 'decimalSeparator' ) && + defaultLocale.hasOwnProperty( 'thousandSeparator' ) && + defaultLocale.hasOwnProperty( 'symbolPosition' ) + ) { + currency.decimalSeparator = defaultLocale.decimalSeparator; + currency.thousandSeparator = defaultLocale.thousandSeparator; + currency.symbolPosition = defaultLocale.symbolPosition; + } + + return Currency( currency ); + } + + return null; +}; + /** * Determines if the given currency is zero decimal. * @@ -105,13 +147,17 @@ export const formatExportAmount = ( amount, currencyCode ) => { * @param {number} amount Amount * @param {string} currencyCode Currency code * @param {string} baseCurrencyCode Base Currency code to override decimal and thousand separators + * @param {boolean} useLocaleFormatting If `baseCurrencyCode` isn't provided, the currency will be + * formatted based on the store's country currency. This parameter allows to override this behavior + * and get the default formatting. * * @return {string} formatted currency representation */ export const formatCurrency = ( amount, currencyCode = 'USD', - baseCurrencyCode = null + baseCurrencyCode = null, + useLocaleFormatting = false ) => { // Normalize amount with respect to zer decimal currencies and provided data formats const isZeroDecimal = isZeroDecimalCurrency( currencyCode ); @@ -122,7 +168,9 @@ export const formatCurrency = ( const isNegative = amount < 0; const positiveAmount = isNegative ? -1 * amount : amount; const prefix = isNegative ? '-' : ''; - const currency = getCurrency( currencyCode, baseCurrencyCode ); + const currency = useLocaleFormatting + ? getCurrencyByLocale( currencyCode ) + : getCurrency( currencyCode, baseCurrencyCode ); if ( currency === null ) { return ( diff --git a/client/utils/currency/test/index.js b/client/utils/currency/test/index.js index 72a2f35f4e6..25e67b62c3c 100644 --- a/client/utils/currency/test/index.js +++ b/client/utils/currency/test/index.js @@ -13,7 +13,7 @@ describe( 'Currency utilities', () => { jest.clearAllMocks(); global.wcpaySettings = { shouldUseExplicitPrice: true, - zeroDecimalCurrencies: [ 'vnd', 'jpy' ], + zeroDecimalCurrencies: [ 'vnd', 'jpy', 'xpf' ], connect: { country: 'US', }, @@ -25,6 +25,11 @@ describe( 'Currency utilities', () => { thousandSeparator: ',', decimalSeparator: '.', precision: 2, + defaultLocale: { + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + }, }, JP: { code: 'JPY', @@ -33,6 +38,11 @@ describe( 'Currency utilities', () => { thousandSeparator: ',', decimalSeparator: '.', precision: 0, + defaultLocale: { + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + }, }, FR: { code: 'EUR', @@ -41,6 +51,11 @@ describe( 'Currency utilities', () => { thousandSeparator: ' ', decimalSeparator: ',', precision: 2, + defaultLocale: { + symbolPosition: 'right_space', + thousandSeparator: ',', + decimalSeparator: '.', + }, }, GB: { code: 'GBP', @@ -49,6 +64,11 @@ describe( 'Currency utilities', () => { thousandSeparator: ',', decimalSeparator: '.', precision: 2, + defaultLocale: { + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + }, }, IN: { code: 'INR', @@ -57,6 +77,24 @@ describe( 'Currency utilities', () => { thousandSeparator: ',', decimalSeparator: '.', precision: 2, + defaultLocale: { + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + }, + }, + NC: { + code: 'XPF', + symbol: 'XPF', + symbolPosition: 'right_space', + thousandSeparator: ' ', + decimalSeparator: '.', + precision: 0, + defaultLocale: { + symbolPosition: 'left_space', + thousandSeparator: ',', + decimalSeparator: '.', + }, }, RU: { code: 'RUB', @@ -65,6 +103,11 @@ describe( 'Currency utilities', () => { thousandSeparator: ' ', decimalSeparator: ',', precision: 2, + defaultLocale: { + symbolPosition: 'right_space', + thousandSeparator: ' ', + decimalSeparator: ',', + }, }, }, }; @@ -110,6 +153,27 @@ describe( 'Currency utilities', () => { expect( utils.formatCurrency( 100000, 'EUR' ) ).toEqual( '€1,000.00' ); } ); + test( 'getCurrencyLocale should use store country currency when it matches', () => { + expect( utils.formatCurrency( 100000, 'USD', null, true ) ).toEqual( + '$1,000.00' + ); + + global.wcpaySettings.connect.country = 'NC'; + + expect( utils.formatCurrency( 100000, 'XPF', null, true ) ).toEqual( + '100 000 XPF' + ); + } ); + + test( 'getCurrencyLocale should use default locale formatting', () => { + expect( utils.formatCurrency( 100000, 'EUR', null, true ) ).toEqual( + '1,000.00 €' + ); + expect( utils.formatCurrency( 100000, 'XPF', null, true ) ).toEqual( + 'XPF 100,000' + ); + } ); + test( 'format export amounts', () => { expect( utils.formatExportAmount( 1000, 'USD' ) ).toEqual( 10 ); expect( utils.formatExportAmount( 1250, 'USD' ) ).toEqual( 12.5 ); diff --git a/client/utils/express-checkout/index.js b/client/utils/express-checkout/index.js new file mode 100644 index 00000000000..fb8fc89bc39 --- /dev/null +++ b/client/utils/express-checkout/index.js @@ -0,0 +1,61 @@ +/* global wcpayPaymentRequestParams, wcpayExpressCheckoutParams */ + +/** + * 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 wcpayExpressCheckoutParams === 'object' && + wcpayExpressCheckoutParams.hasOwnProperty( key ) + ) { + return wcpayExpressCheckoutParams[ key ]; + } + if ( + typeof wcpayPaymentRequestParams === 'object' && + wcpayPaymentRequestParams.hasOwnProperty( key ) + ) { + return wcpayPaymentRequestParams[ key ]; + } + return null; +}; + +/** + * Get WC AJAX endpoint URL. + * + * @param {string} endpoint Endpoint. + * @return {string} URL with interpolated endpoint. + */ +export const getPaymentRequestAjaxURL = ( endpoint ) => + getPaymentRequestData( 'wc_ajax_url' ) + .toString() + .replace( '%%endpoint%%', 'wcpay_' + endpoint ); + +/** + * Construct WC AJAX endpoint URL. + * + * @param {string} ajaxURL AJAX URL. + * @param {string} endpoint Request endpoint URL. + * @param {string} prefix Optional prefix for endpoint action. + * @return {string} URL with interpolated ednpoint. + */ +export const buildAjaxURL = ( ajaxURL, endpoint, prefix = 'wcpay_' ) => + ajaxURL.toString().replace( '%%endpoint%%', prefix + endpoint ); + +/** + * Whether or not to use Google Pay branded button in Chrome. + * + * @return {boolean} Use Google Pay button in Chrome. + */ +export const shouldUseGooglePayBrand = () => { + const ua = window.navigator.userAgent.toLowerCase(); + const isChrome = + /chrome/.test( ua ) && + ! /edge|edg|opr|brave\//.test( ua ) && + window.navigator.vendor === 'Google Inc.'; + // newer versions of Brave do not have the userAgent string + const isBrave = isChrome && window.navigator.brave; + return isChrome && ! isBrave; +}; diff --git a/composer.json b/composer.json index ef48cf5fb9a..ecad75b952b 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,11 @@ "require": { "php": ">=7.2", "ext-json": "*", - "automattic/jetpack-connection": "2.7.5", + "automattic/jetpack-connection": "2.8.2", "automattic/jetpack-config": "1.15.2", "automattic/jetpack-autoloader": "2.11.18", - "automattic/jetpack-identity-crisis": "0.18.5", - "automattic/jetpack-sync": "2.15.0", + "automattic/jetpack-identity-crisis": "0.19.0", + "automattic/jetpack-sync": "2.16.3", "woocommerce/subscriptions-core": "6.7.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 9613a9ba1d3..8642a05ca72 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "90d963a2936e514705b287b12169ee70", + "content-hash": "2fdb6e373349308e7e7855402cf48871", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -114,24 +114,24 @@ }, { "name": "automattic/jetpack-assets", - "version": "v2.1.8", + "version": "v2.1.10", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-assets.git", - "reference": "ae03b367983624c81bda8f1a5ab7dcf903e4012e" + "reference": "f4da7331e5bd2a0c511b8569e1028d5195e4bcf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-assets/zipball/ae03b367983624c81bda8f1a5ab7dcf903e4012e", - "reference": "ae03b367983624c81bda8f1a5ab7dcf903e4012e", + "url": "https://api.github.com/repos/Automattic/jetpack-assets/zipball/f4da7331e5bd2a0c511b8569e1028d5195e4bcf8", + "reference": "f4da7331e5bd2a0c511b8569e1028d5195e4bcf8", "shasum": "" }, "require": { - "automattic/jetpack-constants": "^2.0.1", + "automattic/jetpack-constants": "^2.0.2", "php": ">=7.0" }, "require-dev": { - "automattic/jetpack-changelogger": "^4.2.2", + "automattic/jetpack-changelogger": "^4.2.3", "brain/monkey": "2.6.1", "wikimedia/testing-access-wrapper": "^1.0 || ^2.0 || ^3.0", "yoast/phpunit-polyfills": "1.1.0" @@ -165,9 +165,9 @@ ], "description": "Asset management utilities for Jetpack ecosystem packages", "support": { - "source": "https://github.com/Automattic/jetpack-assets/tree/v2.1.8" + "source": "https://github.com/Automattic/jetpack-assets/tree/v2.1.10" }, - "time": "2024-04-22T18:47:55+00:00" + "time": "2024-05-16T10:58:04+00:00" }, { "name": "automattic/jetpack-autoloader", @@ -269,30 +269,32 @@ }, { "name": "automattic/jetpack-connection", - "version": "v2.7.5", + "version": "v2.8.2", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-connection.git", - "reference": "347155d6536689213e88691585e006fcd01af28f" + "reference": "11441e20c4fa657f182bc94861d14ed5e320ab01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-connection/zipball/347155d6536689213e88691585e006fcd01af28f", - "reference": "347155d6536689213e88691585e006fcd01af28f", + "url": "https://api.github.com/repos/Automattic/jetpack-connection/zipball/11441e20c4fa657f182bc94861d14ed5e320ab01", + "reference": "11441e20c4fa657f182bc94861d14ed5e320ab01", "shasum": "" }, "require": { "automattic/jetpack-a8c-mc-stats": "^2.0.1", "automattic/jetpack-admin-ui": "^0.4.2", - "automattic/jetpack-assets": "^2.1.8", + "automattic/jetpack-assets": "^2.1.10", "automattic/jetpack-constants": "^2.0.2", "automattic/jetpack-redirect": "^2.0.2", "automattic/jetpack-roles": "^2.0.2", - "automattic/jetpack-status": "^3.0.1", + "automattic/jetpack-status": "^3.0.3", "php": ">=7.0" }, "require-dev": { - "automattic/jetpack-changelogger": "^4.2.2", + "automattic/jetpack-changelogger": "^4.2.3", + "automattic/jetpack-licensing": "@dev", + "automattic/jetpack-sync": "@dev", "automattic/wordbless": "@dev", "brain/monkey": "2.6.1", "yoast/phpunit-polyfills": "1.1.0" @@ -312,7 +314,13 @@ "link-template": "https://github.com/Automattic/jetpack-connection/compare/v${old}...v${new}" }, "branch-alias": { - "dev-trunk": "2.7.x-dev" + "dev-trunk": "2.8.x-dev" + }, + "dependencies": { + "test-only": [ + "packages/licensing", + "packages/sync" + ] } }, "autoload": { @@ -328,9 +336,9 @@ ], "description": "Everything needed to connect to the Jetpack infrastructure", "support": { - "source": "https://github.com/Automattic/jetpack-connection/tree/v2.7.5" + "source": "https://github.com/Automattic/jetpack-connection/tree/v2.8.2" }, - "time": "2024-04-30T19:02:20+00:00" + "time": "2024-05-16T10:58:12+00:00" }, { "name": "automattic/jetpack-constants", @@ -385,28 +393,28 @@ }, { "name": "automattic/jetpack-identity-crisis", - "version": "v0.18.5", + "version": "v0.19.0", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-identity-crisis.git", - "reference": "a6250f3d40969e786ac7b3e4527e6efd7b663fcc" + "reference": "a29d1be468e0b567c6e4248c6a2017e9597d5ca8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-identity-crisis/zipball/a6250f3d40969e786ac7b3e4527e6efd7b663fcc", - "reference": "a6250f3d40969e786ac7b3e4527e6efd7b663fcc", + "url": "https://api.github.com/repos/Automattic/jetpack-identity-crisis/zipball/a29d1be468e0b567c6e4248c6a2017e9597d5ca8", + "reference": "a29d1be468e0b567c6e4248c6a2017e9597d5ca8", "shasum": "" }, "require": { - "automattic/jetpack-assets": "^2.1.8", - "automattic/jetpack-connection": "^2.7.4", - "automattic/jetpack-constants": "^2.0.1", + "automattic/jetpack-assets": "^2.1.10", + "automattic/jetpack-connection": "^2.8.2", + "automattic/jetpack-constants": "^2.0.2", "automattic/jetpack-logo": "^2.0.2", - "automattic/jetpack-status": "^3.0.0", + "automattic/jetpack-status": "^3.0.3", "php": ">=7.0" }, "require-dev": { - "automattic/jetpack-changelogger": "^4.2.2", + "automattic/jetpack-changelogger": "^4.2.3", "automattic/wordbless": "@dev", "yoast/phpunit-polyfills": "1.1.0" }, @@ -425,7 +433,7 @@ "link-template": "https://github.com/Automattic/jetpack-identity-crisis/compare/v${old}...v${new}" }, "branch-alias": { - "dev-trunk": "0.18.x-dev" + "dev-trunk": "0.19.x-dev" } }, "autoload": { @@ -439,9 +447,9 @@ ], "description": "Identity Crisis.", "support": { - "source": "https://github.com/Automattic/jetpack-identity-crisis/tree/v0.18.5" + "source": "https://github.com/Automattic/jetpack-identity-crisis/tree/v0.19.0" }, - "time": "2024-04-29T12:43:56+00:00" + "time": "2024-05-16T10:58:31+00:00" }, { "name": "automattic/jetpack-ip", @@ -705,16 +713,16 @@ }, { "name": "automattic/jetpack-status", - "version": "v3.0.1", + "version": "v3.0.3", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-status.git", - "reference": "6ca640be9802be931675c2f8c1e80db6c4e909dd" + "reference": "dee3a6f2dcc129bd3869c44ba661484adda0a2f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-status/zipball/6ca640be9802be931675c2f8c1e80db6c4e909dd", - "reference": "6ca640be9802be931675c2f8c1e80db6c4e909dd", + "url": "https://api.github.com/repos/Automattic/jetpack-status/zipball/dee3a6f2dcc129bd3869c44ba661484adda0a2f8", + "reference": "dee3a6f2dcc129bd3869c44ba661484adda0a2f8", "shasum": "" }, "require": { @@ -722,8 +730,11 @@ "php": ">=7.0" }, "require-dev": { - "automattic/jetpack-changelogger": "^4.2.2", + "automattic/jetpack-changelogger": "^4.2.3", + "automattic/jetpack-connection": "@dev", + "automattic/jetpack-identity-crisis": "@dev", "automattic/jetpack-ip": "^0.2.2", + "automattic/jetpack-plans": "@dev", "brain/monkey": "2.6.1", "yoast/phpunit-polyfills": "1.1.0" }, @@ -739,6 +750,13 @@ }, "branch-alias": { "dev-trunk": "3.0.x-dev" + }, + "dependencies": { + "test-only": [ + "packages/connection", + "packages/identity-crisis", + "packages/plans" + ] } }, "autoload": { @@ -752,36 +770,38 @@ ], "description": "Used to retrieve information about the current status of Jetpack and the site overall.", "support": { - "source": "https://github.com/Automattic/jetpack-status/tree/v3.0.1" + "source": "https://github.com/Automattic/jetpack-status/tree/v3.0.3" }, - "time": "2024-04-30T19:02:07+00:00" + "time": "2024-05-08T10:06:11+00:00" }, { "name": "automattic/jetpack-sync", - "version": "v2.15.0", + "version": "v2.16.3", "source": { "type": "git", "url": "https://github.com/Automattic/jetpack-sync.git", - "reference": "745d3daec54d56dd0ee390c43ca5ca98dba5bb2a" + "reference": "f98ab01117b57ac948616a284917f6ff3db8cbcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/jetpack-sync/zipball/745d3daec54d56dd0ee390c43ca5ca98dba5bb2a", - "reference": "745d3daec54d56dd0ee390c43ca5ca98dba5bb2a", + "url": "https://api.github.com/repos/Automattic/jetpack-sync/zipball/f98ab01117b57ac948616a284917f6ff3db8cbcf", + "reference": "f98ab01117b57ac948616a284917f6ff3db8cbcf", "shasum": "" }, "require": { - "automattic/jetpack-connection": "^2.7.5", + "automattic/jetpack-connection": "^2.8.2", "automattic/jetpack-constants": "^2.0.2", - "automattic/jetpack-identity-crisis": "^0.18.5", + "automattic/jetpack-identity-crisis": "^0.19.0", "automattic/jetpack-ip": "^0.2.2", "automattic/jetpack-password-checker": "^0.3.1", "automattic/jetpack-roles": "^2.0.2", - "automattic/jetpack-status": "^3.0.1", + "automattic/jetpack-status": "^3.0.3", "php": ">=7.0" }, "require-dev": { - "automattic/jetpack-changelogger": "^4.2.2", + "automattic/jetpack-changelogger": "^4.2.3", + "automattic/jetpack-search": "@dev", + "automattic/jetpack-waf": "^0.16.7", "automattic/wordbless": "@dev", "yoast/phpunit-polyfills": "1.1.0" }, @@ -800,7 +820,13 @@ "link-template": "https://github.com/Automattic/jetpack-sync/compare/v${old}...v${new}" }, "branch-alias": { - "dev-trunk": "2.15.x-dev" + "dev-trunk": "2.16.x-dev" + }, + "dependencies": { + "test-only": [ + "packages/search", + "packages/waf" + ] } }, "autoload": { @@ -814,9 +840,9 @@ ], "description": "Everything needed to allow syncing to the WP.com infrastructure.", "support": { - "source": "https://github.com/Automattic/jetpack-sync/tree/v2.15.0" + "source": "https://github.com/Automattic/jetpack-sync/tree/v2.16.3" }, - "time": "2024-04-30T19:02:40+00:00" + "time": "2024-05-16T10:58:32+00:00" }, { "name": "composer/installers", diff --git a/dev/phpcs/Sniffs/DisallowHooksInConstructorSniff.php b/dev/phpcs/WCPay/Sniffs/Hooks/DisallowHooksInConstructorSniff.php similarity index 94% rename from dev/phpcs/Sniffs/DisallowHooksInConstructorSniff.php rename to dev/phpcs/WCPay/Sniffs/Hooks/DisallowHooksInConstructorSniff.php index 519082159a7..ac4bdf38f4f 100644 --- a/dev/phpcs/Sniffs/DisallowHooksInConstructorSniff.php +++ b/dev/phpcs/WCPay/Sniffs/Hooks/DisallowHooksInConstructorSniff.php @@ -3,7 +3,7 @@ * This sniff prohibits the use of add_action and add_filter in __construct. */ -namespace WCPay\CodingStandards\Sniffs; +namespace WCPay\Sniffs\Hooks; use PHP_CodeSniffer\Sniffs\Sniff; use PHP_CodeSniffer\Files\File; @@ -60,7 +60,7 @@ public function process( File $phpcsFile, $stackPtr ) { $phpcsFile->addError( "Usage of $currentTokenContent in __construct() is not allowed", $i, - 'WCPay.CodingStandards.DisallowHooksInConstructor', + 'Found', [ $token['content'] ] ); } diff --git a/dev/phpcs/WCPay/ruleset.xml b/dev/phpcs/WCPay/ruleset.xml new file mode 100644 index 00000000000..9806ccfe9e7 --- /dev/null +++ b/dev/phpcs/WCPay/ruleset.xml @@ -0,0 +1,27 @@ + + + WCPay custom coding standards. + + + + */includes/subscriptions/* + */includes/compat/subscriptions/* + + + */includes/emails/* + + + */includes/class-woopay-tracker.php + */includes/woopay/* + */includes/woopay-user/* + + */includes/class-wc-payments-order-success-page.php + + + */includes/class-wc-payments-customer-service.php + */includes/class-wc-payments-token-service.php + + + */includes/class-wc-payments-webhook-reliability-service.php + + diff --git a/dev/phpcs/ruleset.xml b/dev/phpcs/ruleset.xml deleted file mode 100644 index f9699a102f0..00000000000 --- a/dev/phpcs/ruleset.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - WCPay custom coding standards. - - - */includes/subscriptions/* - */includes/compat/subscriptions/* - - - */includes/emails/* - - - */includes/class-woopay-tracker.php - */includes/woopay/* - */includes/woopay-user/* - */includes/class-wc-payments-order-success-page.php - - - */includes/class-wc-payments-customer-service.php - */includes/class-wc-payments-token-service.php - - - */includes/class-wc-payments-webhook-reliability-service.php - diff --git a/docker/wordpress_xdebug/Dockerfile b/docker/wordpress_xdebug/Dockerfile index 4a93b5dbf61..7b612f20806 100755 --- a/docker/wordpress_xdebug/Dockerfile +++ b/docker/wordpress_xdebug/Dockerfile @@ -1,9 +1,10 @@ FROM wordpress:php7.4 ARG XDEBUG_REMOTE_PORT +ARG XDEBUG_REMOTE_HOST=host.docker.internal RUN pecl install xdebug-2.9.8 \ && echo 'xdebug.remote_enable=1' >> $PHP_INI_DIR/php.ini \ && echo "xdebug.remote_port=$XDEBUG_REMOTE_PORT" >> $PHP_INI_DIR/php.ini \ - && echo 'xdebug.remote_host=host.docker.internal' >> $PHP_INI_DIR/php.ini \ + && echo "xdebug.remote_host=$XDEBUG_REMOTE_HOST" >> $PHP_INI_DIR/php.ini \ && echo 'xdebug.remote_autostart=0' >> $PHP_INI_DIR/php.ini \ && docker-php-ext-enable xdebug RUN apt-get update \ diff --git a/i18n/currency-info.php b/i18n/currency-info.php index 6cfe8d46ed6..652d30cabc2 100644 --- a/i18n/currency-info.php +++ b/i18n/currency-info.php @@ -930,7 +930,7 @@ 'fr_NC' => $global_formats['rs_comma_space_ltr'], 'fr_PF' => $global_formats['rs_comma_space_ltr'], 'fr_WF' => $global_formats['rs_comma_space_ltr'], - 'default' => $global_formats['rs_comma_space_ltr'], + 'default' => $global_formats['ls_dot_comma_ltr'], ], 'YER' => [ 'ar_YE' => $global_formats['rs_comma_dot_rtl'], diff --git a/i18n/locale-info.php b/i18n/locale-info.php index 59c234ae970..cadda2f3c7e 100644 --- a/i18n/locale-info.php +++ b/i18n/locale-info.php @@ -2559,7 +2559,7 @@ 'currency_code' => 'XPF', 'currency_pos' => 'right_space', 'thousand_sep' => ' ', - 'decimal_sep' => ',', + 'decimal_sep' => '.', 'num_decimals' => 0, 'weight_unit' => 'kg', 'dimension_unit' => 'cm', @@ -2783,7 +2783,7 @@ 'currency_code' => 'XPF', 'currency_pos' => 'right_space', 'thousand_sep' => ' ', - 'decimal_sep' => ',', + 'decimal_sep' => '.', 'num_decimals' => 0, 'weight_unit' => 'kg', 'dimension_unit' => 'cm', @@ -3855,7 +3855,7 @@ 'currency_code' => 'XPF', 'currency_pos' => 'right_space', 'thousand_sep' => ' ', - 'decimal_sep' => ',', + 'decimal_sep' => '.', 'num_decimals' => 0, 'weight_unit' => 'kg', 'dimension_unit' => 'cm', diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 7c5d64db371..f302580b0b7 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -756,14 +756,20 @@ private function get_js_settings(): array { $currency_data = []; foreach ( $locale_info as $key => $value ) { - $currency_code = $value['currency_code'] ?? ''; - $currency_data[ $key ] = [ + $currency_code = $value['currency_code'] ?? ''; + $default_locale_formatting = $value['locales']['default'] ?? []; + $currency_data[ $key ] = [ 'code' => $currency_code, 'symbol' => $value['short_symbol'] ?? $symbols[ $currency_code ] ?? '', 'symbolPosition' => $value['currency_pos'] ?? '', 'thousandSeparator' => $value['thousand_sep'] ?? '', 'decimalSeparator' => $value['decimal_sep'] ?? '', 'precision' => $value['num_decimals'], + 'defaultLocale' => [ + 'symbolPosition' => $default_locale_formatting['currency_pos'] ?? '', + 'thousandSeparator' => $default_locale_formatting['thousand_sep'] ?? '', + 'decimalSeparator' => $default_locale_formatting['decimal_sep'] ?? '', + ], ]; } @@ -827,7 +833,6 @@ private function get_js_settings(): array { 'isSetupCompleted' => get_option( 'wcpay_multi_currency_setup_completed' ), ], 'isMultiCurrencyEnabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), - 'isClientEncryptionEligible' => WC_Payments_Features::is_client_secret_encryption_eligible(), 'shouldUseExplicitPrice' => WC_Payments_Explicit_Price_Formatter::should_output_explicit_price(), 'overviewTasksVisibility' => [ 'dismissedTodoTasks' => get_option( 'woocommerce_dismissed_todo_tasks', [] ), diff --git a/includes/admin/class-wc-rest-payments-charges-controller.php b/includes/admin/class-wc-rest-payments-charges-controller.php index 08cc937668a..34422cac647 100644 --- a/includes/admin/class-wc-rest-payments-charges-controller.php +++ b/includes/admin/class-wc-rest-payments-charges-controller.php @@ -81,7 +81,7 @@ public function generate_charge_from_order( $request ) { $currency = $order->get_currency(); $amount = WC_Payments_Utils::prepare_amount( $order->get_total(), $currency ); - $billing_details = WC_Payments_Utils::get_billing_details_from_order( $order ); + $billing_details = WC_Payments::get_order_service()->get_billing_data_from_order( $order ); // TODO: Inject order_service after #7464 is fixed. $date_created = $order->get_date_created(); $intent_id = $order->get_meta( '_intent_id' ); $intent_status = $order->get_meta( '_intent_status' ); @@ -102,7 +102,7 @@ public function generate_charge_from_order( $request ) { 'currency' => $currency, 'disputed' => false, 'outcome' => false, - 'order' => WC_Payments::get_payments_api_client()->build_order_info( $order ), + 'order' => $this->api_client->build_order_info( $order ), 'paid' => false, 'paydown' => null, 'payment_intent' => ! empty( $intent_id ) ? $intent_id : null, @@ -119,7 +119,7 @@ public function generate_charge_from_order( $request ) { 'status' => ! empty( $intent_status ) ? $intent_status : $order->get_status(), ]; - $charge = WC_Payments::get_payments_api_client()->add_formatted_address_to_charge_object( $charge ); + $charge = $this->api_client->add_formatted_address_to_charge_object( $charge ); return rest_ensure_response( $charge ); } diff --git a/includes/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index 9664d5a54a5..9325a07984a 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -470,61 +470,60 @@ public function get_settings(): WP_REST_Response { return new WP_REST_Response( [ - 'enabled_payment_method_ids' => $enabled_payment_methods, - 'available_payment_method_ids' => $available_upe_payment_methods, - 'payment_method_statuses' => $this->wcpay_gateway->get_upe_enabled_payment_method_statuses(), - 'duplicated_payment_method_ids' => $this->wcpay_gateway->find_duplicates(), - 'is_wcpay_enabled' => $this->wcpay_gateway->is_enabled(), - 'is_manual_capture_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'manual_capture' ), - 'is_test_mode_enabled' => WC_Payments::mode()->is_test(), - 'is_dev_mode_enabled' => WC_Payments::mode()->is_dev(), - 'is_multi_currency_enabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), - 'is_client_secret_encryption_enabled' => WC_Payments_Features::is_client_secret_encryption_enabled(), - 'is_wcpay_subscriptions_enabled' => WC_Payments_Features::is_wcpay_subscriptions_enabled(), - 'is_stripe_billing_enabled' => WC_Payments_Features::is_stripe_billing_enabled(), - 'is_wcpay_subscriptions_eligible' => WC_Payments_Features::is_wcpay_subscriptions_eligible(), - 'is_subscriptions_plugin_active' => $this->wcpay_gateway->is_subscriptions_plugin_active(), - 'account_country' => $this->wcpay_gateway->get_option( 'account_country' ), - 'account_statement_descriptor' => $this->wcpay_gateway->get_option( 'account_statement_descriptor' ), - 'account_statement_descriptor_kanji' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kanji' ), - 'account_statement_descriptor_kana' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kana' ), - 'account_business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ), - 'account_business_url' => $this->wcpay_gateway->get_option( 'account_business_url' ), - 'account_business_support_address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ), - 'account_business_support_email' => $this->wcpay_gateway->get_option( 'account_business_support_email' ), - 'account_business_support_phone' => $this->wcpay_gateway->get_option( 'account_business_support_phone' ), - 'account_branding_logo' => $this->wcpay_gateway->get_option( 'account_branding_logo' ), - 'account_branding_icon' => $this->wcpay_gateway->get_option( 'account_branding_icon' ), - 'account_branding_primary_color' => $this->wcpay_gateway->get_option( 'account_branding_primary_color' ), - 'account_branding_secondary_color' => $this->wcpay_gateway->get_option( 'account_branding_secondary_color' ), - 'account_domestic_currency' => $this->wcpay_gateway->get_option( 'account_domestic_currency' ), - 'is_payment_request_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'payment_request' ), - 'is_debug_log_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'enable_logging' ), - 'payment_request_enabled_locations' => $this->wcpay_gateway->get_option( 'payment_request_button_locations' ), - 'payment_request_button_size' => $this->wcpay_gateway->get_option( 'payment_request_button_size' ), - 'payment_request_button_type' => $this->wcpay_gateway->get_option( 'payment_request_button_type' ), - 'payment_request_button_theme' => $this->wcpay_gateway->get_option( 'payment_request_button_theme' ), - 'is_saved_cards_enabled' => $this->wcpay_gateway->is_saved_cards_enabled(), - 'is_card_present_eligible' => $this->wcpay_gateway->is_card_present_eligible() && isset( WC()->payment_gateways()->get_available_payment_gateways()['cod'] ), - 'is_woopay_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'platform_checkout' ), - 'show_woopay_incompatibility_notice' => get_option( 'woopay_invalid_extension_found', false ), + 'enabled_payment_method_ids' => $enabled_payment_methods, + 'available_payment_method_ids' => $available_upe_payment_methods, + 'payment_method_statuses' => $this->wcpay_gateway->get_upe_enabled_payment_method_statuses(), + 'duplicated_payment_method_ids' => $this->wcpay_gateway->find_duplicates(), + 'is_wcpay_enabled' => $this->wcpay_gateway->is_enabled(), + 'is_manual_capture_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'manual_capture' ), + 'is_test_mode_enabled' => WC_Payments::mode()->is_test(), + 'is_dev_mode_enabled' => WC_Payments::mode()->is_dev(), + 'is_multi_currency_enabled' => WC_Payments_Features::is_customer_multi_currency_enabled(), + 'is_wcpay_subscriptions_enabled' => WC_Payments_Features::is_wcpay_subscriptions_enabled(), + 'is_stripe_billing_enabled' => WC_Payments_Features::is_stripe_billing_enabled(), + 'is_wcpay_subscriptions_eligible' => WC_Payments_Features::is_wcpay_subscriptions_eligible(), + 'is_subscriptions_plugin_active' => $this->wcpay_gateway->is_subscriptions_plugin_active(), + 'account_country' => $this->wcpay_gateway->get_option( 'account_country' ), + 'account_statement_descriptor' => $this->wcpay_gateway->get_option( 'account_statement_descriptor' ), + 'account_statement_descriptor_kanji' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kanji' ), + 'account_statement_descriptor_kana' => $this->wcpay_gateway->get_option( 'account_statement_descriptor_kana' ), + 'account_business_name' => $this->wcpay_gateway->get_option( 'account_business_name' ), + 'account_business_url' => $this->wcpay_gateway->get_option( 'account_business_url' ), + 'account_business_support_address' => $this->wcpay_gateway->get_option( 'account_business_support_address' ), + 'account_business_support_email' => $this->wcpay_gateway->get_option( 'account_business_support_email' ), + 'account_business_support_phone' => $this->wcpay_gateway->get_option( 'account_business_support_phone' ), + 'account_branding_logo' => $this->wcpay_gateway->get_option( 'account_branding_logo' ), + 'account_branding_icon' => $this->wcpay_gateway->get_option( 'account_branding_icon' ), + 'account_branding_primary_color' => $this->wcpay_gateway->get_option( 'account_branding_primary_color' ), + 'account_branding_secondary_color' => $this->wcpay_gateway->get_option( 'account_branding_secondary_color' ), + 'account_domestic_currency' => $this->wcpay_gateway->get_option( 'account_domestic_currency' ), + 'is_payment_request_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'payment_request' ), + 'is_debug_log_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'enable_logging' ), + 'payment_request_enabled_locations' => $this->wcpay_gateway->get_option( 'payment_request_button_locations' ), + 'payment_request_button_size' => $this->wcpay_gateway->get_option( 'payment_request_button_size' ), + 'payment_request_button_type' => $this->wcpay_gateway->get_option( 'payment_request_button_type' ), + 'payment_request_button_theme' => $this->wcpay_gateway->get_option( 'payment_request_button_theme' ), + 'is_saved_cards_enabled' => $this->wcpay_gateway->is_saved_cards_enabled(), + 'is_card_present_eligible' => $this->wcpay_gateway->is_card_present_eligible() && isset( WC()->payment_gateways()->get_available_payment_gateways()['cod'] ), + 'is_woopay_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'platform_checkout' ), + 'show_woopay_incompatibility_notice' => get_option( 'woopay_invalid_extension_found', false ), 'show_express_checkout_incompatibility_notice' => $this->should_show_express_checkout_incompatibility_notice(), - 'woopay_custom_message' => $this->wcpay_gateway->get_option( 'platform_checkout_custom_message' ), - 'woopay_store_logo' => $this->wcpay_gateway->get_option( 'platform_checkout_store_logo' ), - 'woopay_enabled_locations' => $this->wcpay_gateway->get_option( 'platform_checkout_button_locations', array_keys( $wcpay_form_fields['payment_request_button_locations']['options'] ) ), - 'deposit_schedule_interval' => $this->wcpay_gateway->get_option( 'deposit_schedule_interval' ), - 'deposit_schedule_monthly_anchor' => $this->wcpay_gateway->get_option( 'deposit_schedule_monthly_anchor' ), - 'deposit_schedule_weekly_anchor' => $this->wcpay_gateway->get_option( 'deposit_schedule_weekly_anchor' ), - 'deposit_delay_days' => $this->wcpay_gateway->get_option( 'deposit_delay_days' ), - 'deposit_status' => $this->wcpay_gateway->get_option( 'deposit_status' ), - 'deposit_restrictions' => $this->wcpay_gateway->get_option( 'deposit_restrictions' ), - 'deposit_completed_waiting_period' => $this->wcpay_gateway->get_option( 'deposit_completed_waiting_period' ), - 'reporting_export_language' => $this->wcpay_gateway->get_option( 'reporting_export_language' ), - 'current_protection_level' => $this->wcpay_gateway->get_option( 'current_protection_level' ), - 'advanced_fraud_protection_settings' => $this->wcpay_gateway->get_option( 'advanced_fraud_protection_settings' ), - 'is_migrating_stripe_billing' => $is_migrating_stripe_billing ?? false, - 'stripe_billing_subscription_count' => $stripe_billing_subscription_count ?? 0, - 'stripe_billing_migrated_count' => $stripe_billing_migrated_count ?? 0, + 'woopay_custom_message' => $this->wcpay_gateway->get_option( 'platform_checkout_custom_message' ), + 'woopay_store_logo' => $this->wcpay_gateway->get_option( 'platform_checkout_store_logo' ), + 'woopay_enabled_locations' => $this->wcpay_gateway->get_option( 'platform_checkout_button_locations', array_keys( $wcpay_form_fields['payment_request_button_locations']['options'] ) ), + 'deposit_schedule_interval' => $this->wcpay_gateway->get_option( 'deposit_schedule_interval' ), + 'deposit_schedule_monthly_anchor' => $this->wcpay_gateway->get_option( 'deposit_schedule_monthly_anchor' ), + 'deposit_schedule_weekly_anchor' => $this->wcpay_gateway->get_option( 'deposit_schedule_weekly_anchor' ), + 'deposit_delay_days' => $this->wcpay_gateway->get_option( 'deposit_delay_days' ), + 'deposit_status' => $this->wcpay_gateway->get_option( 'deposit_status' ), + 'deposit_restrictions' => $this->wcpay_gateway->get_option( 'deposit_restrictions' ), + 'deposit_completed_waiting_period' => $this->wcpay_gateway->get_option( 'deposit_completed_waiting_period' ), + 'reporting_export_language' => $this->wcpay_gateway->get_option( 'reporting_export_language' ), + 'current_protection_level' => $this->wcpay_gateway->get_option( 'current_protection_level' ), + 'advanced_fraud_protection_settings' => $this->wcpay_gateway->get_option( 'advanced_fraud_protection_settings' ), + 'is_migrating_stripe_billing' => $is_migrating_stripe_billing ?? false, + 'stripe_billing_subscription_count' => $stripe_billing_subscription_count ?? 0, + 'stripe_billing_migrated_count' => $stripe_billing_migrated_count ?? 0, ] ); } @@ -541,7 +540,6 @@ public function update_settings( WP_REST_Request $request ) { $this->update_is_test_mode_enabled( $request ); $this->update_is_debug_log_enabled( $request ); $this->update_is_multi_currency_enabled( $request ); - $this->update_is_client_secret_encryption_enabled( $request ); $this->update_is_wcpay_subscriptions_enabled( $request ); $this->update_is_payment_request_enabled( $request ); $this->update_payment_request_enabled_locations( $request ); @@ -747,21 +745,6 @@ private function update_is_multi_currency_enabled( WP_REST_Request $request ) { update_option( '_wcpay_feature_customer_multi_currency', $is_multi_currency_enabled ? '1' : '0' ); } - /** - * Updates the client secret encryption feature status. - * - * @param WP_REST_Request $request Request object. - */ - private function update_is_client_secret_encryption_enabled( WP_REST_Request $request ) { - if ( ! $request->has_param( 'is_client_secret_encryption_enabled' ) ) { - return; - } - - $is_client_secret_encryption_enabled = $request->get_param( 'is_client_secret_encryption_enabled' ); - - update_option( '_wcpay_feature_client_secret_encryption', $is_client_secret_encryption_enabled ? '1' : '0' ); - } - /** * Updates the WCPay Subscriptions feature status. * diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index a64817190f3..f20c1b15f1b 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -18,7 +18,7 @@ use WCPay\Constants\Intent_Status; use WCPay\Constants\Payment_Type; use WCPay\Constants\Payment_Method; -use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception}; +use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception }; use WCPay\Core\Server\Request\Cancel_Intention; use WCPay\Core\Server\Request\Capture_Intention; use WCPay\Core\Server\Request\Create_And_Confirm_Intention; @@ -1128,7 +1128,6 @@ public function new_process_payment( WC_Order $order ) { * @param int $order_id Order ID to process the payment for. * * @return array|null An array with result of payment and redirect URL, or nothing. - * @throws Process_Payment_Exception Error processing the payment. * @throws Exception Error processing the payment. */ public function process_payment( $order_id ) { @@ -1141,7 +1140,7 @@ public function process_payment( $order_id ) { try { if ( 20 < strlen( $order->get_billing_phone() ) ) { - throw new Process_Payment_Exception( + throw new Invalid_Phone_Number_Exception( __( 'Invalid phone number.', 'woocommerce-payments' ), 'invalid_phone_number' ); @@ -1151,7 +1150,7 @@ public function process_payment( $order_id ) { $fraud_prevention_service = Fraud_Prevention_Service::get_instance(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( $fraud_prevention_service->is_enabled() && ! $fraud_prevention_service->verify_token( $_POST['wcpay-fraud-prevention-token'] ?? null ) ) { - throw new Process_Payment_Exception( + throw new Fraud_Prevention_Enabled_Exception( __( "We're not able to process this payment. Please refresh the page and try again.", 'woocommerce-payments' ), 'fraud_prevention_enabled' ); @@ -1159,7 +1158,7 @@ public function process_payment( $order_id ) { } if ( $this->failed_transaction_rate_limiter->is_limited() ) { - throw new Process_Payment_Exception( + throw new Rate_Limiter_Enabled_Exception( __( 'Your payment was not processed.', 'woocommerce-payments' ), 'rate_limiter_enabled' ); @@ -1281,7 +1280,7 @@ public function process_payment( $order_id ) { // Re-throw the exception after setting everything up. // This makes the error notice show up both in the regular and block checkout. - throw new Exception( WC_Payments_Utils::get_filtered_error_message( $e, $blocked_by_fraud_rules ) ); + throw new Exception( WC_Payments_Utils::get_filtered_error_message( $e, $blocked_by_fraud_rules ), 0, $e ); } } @@ -1431,19 +1430,6 @@ public function process_payment_for_order( $cart, $payment_information, $schedul ]; list( $user, $customer_id ) = $this->manage_customer_details_for_order( $order, $customer_details_options ); - // Update saved payment method async to include billing details, if missing. - if ( $payment_information->is_using_saved_payment_method() ) { - $this->action_scheduler_service->schedule_job( - time(), - self::UPDATE_SAVED_PAYMENT_METHOD, - [ - 'payment_method' => $payment_information->get_payment_method(), - 'order_id' => $order->get_id(), - 'is_test_mode' => WC_Payments::mode()->is_test(), - ] - ); - } - $intent_failed = false; $payment_needed = $amount > 0; @@ -1461,6 +1447,16 @@ public function process_payment_for_order( $cart, $payment_information, $schedul // We need to make sure the saved payment method is saved to the order so we can // charge the payment method for a future payment. $this->add_token_to_order( $order, $payment_information->get_payment_token() ); + // If we are not hitting the API for the intent, we need to update the saved payment method ourselves. + $this->action_scheduler_service->schedule_job( + time(), + self::UPDATE_SAVED_PAYMENT_METHOD, + [ + 'payment_method' => $payment_information->get_payment_method(), + 'order_id' => $order->get_id(), + 'is_test_mode' => WC_Payments::mode()->is_test(), + ] + ); } if ( $is_changing_payment_method_for_subscription && $payment_information->is_using_saved_payment_method() ) { @@ -1543,6 +1539,16 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $request->set_payment_methods( $payment_methods ); $request->set_cvc_confirmation( $payment_information->get_cvc_confirmation() ); $request->set_hook_args( $payment_information ); + if ( $payment_information->is_using_saved_payment_method() ) { + $billing_details = $this->order_service->get_billing_data_from_order( $order ); + + $is_legacy_card_object = (bool) preg_match( '/^(card_|src_)/', $payment_information->get_payment_method() ); + + // Not updating billing details for legacy card objects because they have a different structure and are no longer supported. + if ( ! empty( $billing_details ) && ! $is_legacy_card_object ) { + $request->set_payment_method_update_data( [ 'billing_details' => $billing_details ] ); + } + } // Add specific payment method parameters to the request. $this->modify_create_intent_parameters_when_processing_payment( $request, $payment_information, $order ); @@ -1735,7 +1741,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul '#wcpay-confirm-%s:%s:%s:%s', $payment_needed ? 'pi' : 'si', $order_id, - WC_Payments_Utils::encrypt_client_secret( $this->account->get_stripe_account_id(), $client_secret ), + $client_secret, wp_create_nonce( 'wcpay_update_order_status_nonce' ) ), // Include the payment method ID so the Blocks integration can save cards. @@ -1775,7 +1781,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul } } else { $payment_method_details = false; - $payment_method_type = $intent->get_payment_method_type(); + $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); } if ( empty( $_POST['payment_request_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification @@ -1960,7 +1966,7 @@ public function process_redirect_payment( $order, $intent_id, $save_payment_meth '#wcpay-confirm-%s:%s:%s:%s', $payment_needed ? 'pi' : 'si', $order_id, - WC_Payments_Utils::encrypt_client_secret( $this->account->get_stripe_account_id(), $client_secret ), + $client_secret, wp_create_nonce( 'wcpay_update_order_status_nonce' ) ); wp_safe_redirect( $redirect_url ); @@ -1972,7 +1978,7 @@ public function process_redirect_payment( $order, $intent_id, $save_payment_meth Logger::log( 'Error: ' . $e->getMessage() ); $is_order_id_mismatched_exception = - is_a( $e, Process_Payment_Exception::class ) + $e instanceof Process_Payment_Exception && self::PROCESS_REDIRECT_ORDER_MISMATCH_ERROR_CODE === $e->get_error_code(); // If the order ID mismatched exception is thrown, do not mark the order as failed. @@ -3430,11 +3436,6 @@ public function update_order_status() { ); } - $payment_method_id = isset( $_POST['payment_method_id'] ) ? wc_clean( wp_unslash( $_POST['payment_method_id'] ) ) : ''; - if ( 'null' === $payment_method_id ) { - $payment_method_id = ''; - } - // Check that the intent saved in the order matches the intent used as part of the // authentication process. The ID of the intent used is sent with // the AJAX request. We are about to use the status of the intent saved in @@ -3447,7 +3448,8 @@ public function update_order_status() { ); } - $amount = $order->get_total(); + $amount = $order->get_total(); + $payment_method_details = false; if ( $amount > 0 ) { // An exception is thrown if an intent can't be found for the given intent ID. @@ -3471,6 +3473,8 @@ public function update_order_status() { $charge_id = ''; } + $payment_method_id = $intent->get_payment_method_id(); + if ( Intent_Status::SUCCEEDED === $status ) { $this->duplicate_payment_prevention_service->remove_session_processing_order( $order->get_id() ); } @@ -3484,6 +3488,11 @@ public function update_order_status() { try { $token = $this->token_service->add_payment_method_to_user( $payment_method_id, wp_get_current_user() ); $this->add_token_to_order( $order, $token ); + + if ( ! empty( $token ) ) { + $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); + $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); + } } catch ( Exception $e ) { // If saving the token fails, log the error message but catch the error to avoid crashing the checkout flow. Logger::log( 'Error when saving payment method: ' . $e->getMessage() ); @@ -3747,10 +3756,7 @@ public function create_setup_intent_ajax() { $setup_intent_output = [ 'id' => $setup_intent->get_id(), 'status' => $setup_intent->get_status(), - 'client_secret' => WC_Payments_Utils::encrypt_client_secret( - $this->account->get_stripe_account_id(), - $setup_intent->get_client_secret() - ), + 'client_secret' => $setup_intent->get_client_secret(), ]; wp_send_json_success( $setup_intent_output, 200 ); @@ -4277,6 +4283,17 @@ private function get_payment_method_type_from_payment_details( $payment_method_d return $payment_method_details['type'] ?? null; } + /** + * Get the payment method used with a setup intent. + * + * @param WC_Payments_API_Setup_Intention $intent The PaymentIntent object. + * @param WC_Payment_Token $token The payment token. + * @return string|null The payment method type. + */ + private function get_payment_method_type_for_setup_intent( $intent, $token ) { + return 'wcpay_link' !== $token->get_type() ? $intent->get_payment_method_type() : Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID; + } + /** * This function wraps WC_Payments::get_payment_method_map, useful for unit testing. * @@ -4447,8 +4464,14 @@ private function handle_afterpay_shipping_requirement( WC_Order $order, Create_A } $billing_data = $this->order_service->get_billing_data_from_order( $order ); - if ( $check_if_usable( $billing_data['address'] ) ) { - $request->set_shipping( $billing_data ); + // Afterpay fails if we send more parameters than expected in the shipping address. + // This ensures that we only send the name and address fields, as in get_shipping_data_from_order. + $shipping_data = [ + 'name' => $billing_data['name'] ?? '', + 'address' => $billing_data['address'] ?? [], + ]; + if ( $check_if_usable( $shipping_data['address'] ) ) { + $request->set_shipping( $shipping_data ); return; } diff --git a/includes/class-wc-payment-token-wcpay-link.php b/includes/class-wc-payment-token-wcpay-link.php index 1b1ac31464b..cf7901f1926 100644 --- a/includes/class-wc-payment-token-wcpay-link.php +++ b/includes/class-wc-payment-token-wcpay-link.php @@ -94,6 +94,16 @@ public function get_redacted_email( $context = 'view' ) { return $this->redact_email_address( $this->get_email( $context ) ); } + /** + * Returns the type of this payment token (CC, eCheck, or something else). + * + * @param string $deprecated Deprecated since WooCommerce 3.0. + * @return string Payment Token Type (CC, eCheck) + */ + public function get_type( $deprecated = '' ) { + return self::TYPE; + } + /** * Transforms email address into redacted/shortened format like ***xxxx@x.com. * Using shortened length of four characters will mimic CC last-4 digits of card number. diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index dc00d7c3b15..b3106527a2d 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -289,7 +289,6 @@ public function get_account_status_data(): array { 'paymentsEnabled' => $account['payments_enabled'], 'detailsSubmitted' => $account['details_submitted'] ?? true, 'deposits' => $account['deposits'] ?? [], - 'depositsStatus' => $account['deposits']['status'] ?? $account['deposits_status'] ?? '', 'currentDeadline' => $account['current_deadline'] ?? false, 'pastDue' => $account['has_overdue_requirements'] ?? false, 'accountLink' => $this->get_login_url(), @@ -525,7 +524,7 @@ public function has_card_readers_available(): bool { * * @return array Fees. */ - public function get_fees() { + public function get_fees(): array { $account = $this->get_cached_account_data(); return ! empty( $account ) && isset( $account['fees'] ) ? $account['fees'] : []; } @@ -1892,7 +1891,7 @@ public function get_account_country() { * * @return string Currency code in lowercase. */ - public function get_account_default_currency() { + public function get_account_default_currency(): string { $account = $this->get_cached_account_data(); return $account['store_currencies']['default'] ?? strtolower( Currency_Code::UNITED_STATES_DOLLAR ); } diff --git a/includes/class-wc-payments-blocks-payment-method.php b/includes/class-wc-payments-blocks-payment-method.php index 947cc94a5c9..36e13e2b25b 100644 --- a/includes/class-wc-payments-blocks-payment-method.php +++ b/includes/class-wc-payments-blocks-payment-method.php @@ -74,20 +74,18 @@ public function get_payment_method_script_handles() { WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', [ 'stripe' ] ); wp_set_script_translations( 'WCPAY_BLOCKS_CHECKOUT', 'woocommerce-payments' ); - if ( WC()->cart ) { - wp_add_inline_script( - 'WCPAY_BLOCKS_CHECKOUT', - 'var wcBlocksCheckoutData = ' . wp_json_encode( - [ - 'amount' => WC()->cart->get_total( '' ), - 'currency' => get_woocommerce_currency(), - 'storeCountry' => WC()->countries->get_base_country(), - 'billingCountry' => WC()->customer->get_billing_country(), - ] - ) . ';', - 'before' - ); - } + wp_add_inline_script( + 'WCPAY_BLOCKS_CHECKOUT', + 'var wcBlocksCheckoutData = ' . wp_json_encode( + [ + 'amount' => WC()->cart ? WC()->cart->get_total( '' ) : 0, + 'currency' => get_woocommerce_currency(), + 'storeCountry' => WC()->countries->get_base_country(), + 'billingCountry' => WC()->customer ? WC()->customer->get_billing_country() : 'US', + ] + ) . ';', + 'before' + ); Fraud_Prevention_Service::maybe_append_fraud_prevention_token(); diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index 5cb398bcdd3..ef9e3ee1fa9 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -173,36 +173,36 @@ public function get_payment_fields_js_config() { $gateway = WC_Payments::get_gateway() ?? $this->gateway; $js_config = [ - 'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ), - 'testMode' => WC_Payments::mode()->is_test(), - 'accountId' => $this->account->get_stripe_account_id(), - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'wcAjaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ), - 'createSetupIntentNonce' => wp_create_nonce( 'wcpay_create_setup_intent_nonce' ), - 'initWooPayNonce' => wp_create_nonce( 'wcpay_init_woopay_nonce' ), - 'saveUPEAppearanceNonce' => wp_create_nonce( 'wcpay_save_upe_appearance_nonce' ), - 'genericErrorMessage' => __( 'There was a problem processing the payment. Please check your email inbox and refresh the page to try again.', 'woocommerce-payments' ), - 'fraudServices' => $this->fraud_service->get_fraud_services_config(), - 'features' => $this->gateway->supports, - 'forceNetworkSavedCards' => WC_Payments::is_network_saved_cards_enabled() || $gateway->should_use_stripe_platform_on_checkout_page(), - 'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ), - 'isPreview' => is_preview(), - 'isSavedCardsEnabled' => $this->gateway->is_saved_cards_enabled(), - 'isWooPayEnabled' => $this->woopay_util->should_enable_woopay( $this->gateway ) && $this->woopay_util->should_enable_woopay_on_cart_or_checkout(), - 'isWoopayExpressCheckoutEnabled' => $this->woopay_util->is_woopay_express_checkout_enabled(), - 'isWoopayFirstPartyAuthEnabled' => $this->woopay_util->is_woopay_first_party_auth_enabled(), - 'isWooPayEmailInputEnabled' => $this->woopay_util->is_woopay_email_input_enabled(), - 'isWooPayDirectCheckoutEnabled' => WC_Payments_Features::is_woopay_direct_checkout_enabled(), - 'isClientEncryptionEnabled' => WC_Payments_Features::is_client_secret_encryption_enabled(), - 'woopayHost' => WooPay_Utilities::get_woopay_url(), - 'platformTrackerNonce' => wp_create_nonce( 'platform_tracks_nonce' ), - 'accountIdForIntentConfirmation' => apply_filters( 'wc_payments_account_id_for_intent_confirmation', '' ), - 'wcpayVersionNumber' => WCPAY_VERSION_NUMBER, - 'woopaySignatureNonce' => wp_create_nonce( 'woopay_signature_nonce' ), - 'woopaySessionNonce' => wp_create_nonce( 'woopay_session_nonce' ), - 'woopayMerchantId' => Jetpack_Options::get_option( 'id' ), - 'icon' => $this->gateway->get_icon_url(), - 'woopayMinimumSessionData' => WooPay_Session::get_woopay_minimum_session_data(), + 'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ), + 'testMode' => WC_Payments::mode()->is_test(), + 'accountId' => $this->account->get_stripe_account_id(), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'wcAjaxUrl' => WC_AJAX::get_endpoint( '%%endpoint%%' ), + 'createSetupIntentNonce' => wp_create_nonce( 'wcpay_create_setup_intent_nonce' ), + 'initWooPayNonce' => wp_create_nonce( 'wcpay_init_woopay_nonce' ), + 'saveUPEAppearanceNonce' => wp_create_nonce( 'wcpay_save_upe_appearance_nonce' ), + 'genericErrorMessage' => __( 'There was a problem processing the payment. Please check your email inbox and refresh the page to try again.', 'woocommerce-payments' ), + 'fraudServices' => $this->fraud_service->get_fraud_services_config(), + 'features' => $this->gateway->supports, + 'forceNetworkSavedCards' => WC_Payments::is_network_saved_cards_enabled() || $gateway->should_use_stripe_platform_on_checkout_page(), + 'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ), + 'isPreview' => is_preview(), + 'isSavedCardsEnabled' => $this->gateway->is_saved_cards_enabled(), + 'isExpressCheckoutElementEnabled' => WC_Payments_Features::is_stripe_ece_enabled(), + 'isWooPayEnabled' => $this->woopay_util->should_enable_woopay( $this->gateway ) && $this->woopay_util->should_enable_woopay_on_cart_or_checkout(), + 'isWoopayExpressCheckoutEnabled' => $this->woopay_util->is_woopay_express_checkout_enabled(), + 'isWoopayFirstPartyAuthEnabled' => $this->woopay_util->is_woopay_first_party_auth_enabled(), + 'isWooPayEmailInputEnabled' => $this->woopay_util->is_woopay_email_input_enabled(), + 'isWooPayDirectCheckoutEnabled' => WC_Payments_Features::is_woopay_direct_checkout_enabled(), + 'woopayHost' => WooPay_Utilities::get_woopay_url(), + 'platformTrackerNonce' => wp_create_nonce( 'platform_tracks_nonce' ), + 'accountIdForIntentConfirmation' => apply_filters( 'wc_payments_account_id_for_intent_confirmation', '' ), + 'wcpayVersionNumber' => WCPAY_VERSION_NUMBER, + 'woopaySignatureNonce' => wp_create_nonce( 'woopay_signature_nonce' ), + 'woopaySessionNonce' => wp_create_nonce( 'woopay_session_nonce' ), + 'woopayMerchantId' => Jetpack_Options::get_option( 'id' ), + 'icon' => $this->gateway->get_icon_url(), + 'woopayMinimumSessionData' => WooPay_Session::get_woopay_minimum_session_data(), ]; /** diff --git a/includes/class-wc-payments-customer-service.php b/includes/class-wc-payments-customer-service.php index 824f7f7a2f9..7903c95b8f3 100644 --- a/includes/class-wc-payments-customer-service.php +++ b/includes/class-wc-payments-customer-service.php @@ -71,24 +71,34 @@ class WC_Payments_Customer_Service { */ private $session_service; + /** + * WC_Payments_Order_Service instance + * + * @var WC_Payments_Order_Service + */ + private $order_service; + /** * Class constructor * * @param WC_Payments_API_Client $payments_api_client Payments API client. - * @param WC_Payments_Account $account WC_Payments_Account instance. - * @param Database_Cache $database_cache Database_Cache instance. - * @param WC_Payments_Session_Service $session_service Session Service class instance. + * @param WC_Payments_Account $account WC_Payments_Account instance. + * @param Database_Cache $database_cache Database_Cache instance. + * @param WC_Payments_Session_Service $session_service Session Service class instance. + * @param WC_Payments_Order_Service $order_service Order Service class instance. */ public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Account $account, Database_Cache $database_cache, - WC_Payments_Session_Service $session_service + WC_Payments_Session_Service $session_service, + WC_Payments_Order_Service $order_service ) { $this->payments_api_client = $payments_api_client; $this->account = $account; $this->database_cache = $database_cache; $this->session_service = $session_service; + $this->order_service = $order_service; /* * Adds the WooCommerce Payments customer ID found in the user session @@ -168,12 +178,12 @@ public function create_customer_for_user( ?WP_User $user, array $customer_data = * * @return string WooPayments customer ID. * @throws API_Exception Throws when server API request fails. -*/ + */ public function get_or_create_customer_id_from_order( ?int $user_id, WC_Order $order ): string { // Determine the customer making the payment, create one if we don't have one already. $customer_id = $this->get_customer_id_by_user_id( $user_id ); $customer_data = self::map_customer_data( $order, new WC_Customer( $user_id ?? 0 ) ); - $user = null === $user_id ? null : get_user_by( 'id', $user_id ); + $user = null === $user_id ? null : get_user_by( 'id', $user_id ); if ( null !== $customer_id ) { $this->update_customer_for_user( $customer_id, $user, $customer_data ); @@ -283,7 +293,7 @@ public function get_payment_methods_for_customer( $customer_id, $type = 'card' ) * @param WC_Order $order Order to be used on the update. */ public function update_payment_method_with_billing_details_from_order( $payment_method_id, $order ) { - $billing_details = WC_Payments_Utils::get_billing_details_from_order( $order ); + $billing_details = $this->order_service->get_billing_data_from_order( $order ); if ( ! empty( $billing_details ) ) { $this->payments_api_client->update_payment_method( @@ -517,9 +527,9 @@ public function get_prepared_customer_data() { } global $wp; - $user_email = ''; - $firstname = ''; - $lastname = ''; + $user_email = ''; + $firstname = ''; + $lastname = ''; $billing_country = ''; if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended @@ -527,9 +537,9 @@ public function get_prepared_customer_data() { $order = wc_get_order( $order_id ); if ( is_a( $order, 'WC_Order' ) ) { - $firstname = $order->get_billing_first_name(); - $lastname = $order->get_billing_last_name(); - $user_email = $order->get_billing_email(); + $firstname = $order->get_billing_first_name(); + $lastname = $order->get_billing_last_name(); + $user_email = $order->get_billing_email(); $billing_country = $order->get_billing_country(); } } @@ -538,17 +548,17 @@ public function get_prepared_customer_data() { $user = wp_get_current_user(); if ( $user->ID ) { - $firstname = $user->user_firstname; - $lastname = $user->user_lastname; - $user_email = get_user_meta( $user->ID, 'billing_email', true ); - $user_email = $user_email ?: $user->user_email; + $firstname = $user->user_firstname; + $lastname = $user->user_lastname; + $user_email = get_user_meta( $user->ID, 'billing_email', true ); + $user_email = ! empty( $user_email ) ? $user_email : $user->user_email; $billing_country = get_user_meta( $user->ID, 'billing_country', true ); } } return [ - 'name' => $firstname . ' ' . $lastname, - 'email' => $user_email, + 'name' => $firstname . ' ' . $lastname, + 'email' => $user_email, 'billing_country' => $billing_country, ]; } diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 59f37816ee0..568ee184b5a 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -17,12 +17,14 @@ class WC_Payments_Features { const WCPAY_SUBSCRIPTIONS_FLAG_NAME = '_wcpay_feature_subscriptions'; const STRIPE_BILLING_FLAG_NAME = '_wcpay_feature_stripe_billing'; + const STRIPE_ECE_FLAG_NAME = '_wcpay_feature_stripe_ece'; const WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_express_checkout'; const WOOPAY_FIRST_PARTY_AUTH_FLAG_NAME = '_wcpay_feature_woopay_first_party_auth'; const WOOPAY_DIRECT_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_direct_checkout'; 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 +38,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. * @@ -67,30 +78,6 @@ public static function is_customer_multi_currency_enabled() { return '1' === get_option( '_wcpay_feature_customer_multi_currency', '1' ); } - /** - * Returns if the encryption libraries are loaded and the encrypt method exists. - * - * @return bool - */ - public static function is_client_secret_encryption_eligible() { - return extension_loaded( 'openssl' ) && function_exists( 'openssl_encrypt' ); - } - - /** - * Checks whether the client secret encryption feature is enabled. - * - * @return bool - */ - public static function is_client_secret_encryption_enabled() { - $enabled = '1' === get_option( '_wcpay_feature_client_secret_encryption', '0' ); - // Check if it can be enabled when it's enabled, it needs openssl to operate. - if ( $enabled && ! self::is_client_secret_encryption_eligible() ) { - update_option( '_wcpay_feature_client_secret_encryption', '0' ); - $enabled = false; - } - return $enabled; - } - /** * Checks whether WCPay Subscriptions is enabled. * @@ -350,6 +337,15 @@ public static function should_use_stripe_billing() { return false; } + /** + * Checks whether the Stripe Express Checkout Element feature is enabled. + * + * @return bool + */ + public static function is_stripe_ece_enabled(): bool { + return '1' === get_option( self::STRIPE_ECE_FLAG_NAME, '0' ); + } + /** * Checks whether Dispute issuer evidence feature should be enabled. Disabled by default. * @@ -379,7 +375,6 @@ public static function to_array() { 'multiCurrency' => self::is_customer_multi_currency_enabled(), 'woopay' => self::is_woopay_eligible(), 'documents' => self::is_documents_section_enabled(), - 'clientSecretEncryption' => self::is_client_secret_encryption_enabled(), 'woopayExpressCheckout' => self::is_woopay_express_checkout_enabled(), 'isAuthAndCaptureEnabled' => self::is_auth_and_capture_enabled(), 'isDisputeIssuerEvidenceEnabled' => self::is_dispute_issuer_evidence_enabled(), diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index b3e45084522..b62a693ce33 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -891,30 +891,39 @@ public function get_shipping_data_from_order( WC_Order $order ): array { /** * Create the billing data array to send to Stripe when making a purchase, based on order's billing data. + * It only returns the fields that are present in the billing section of the checkout. * * @param WC_Order $order The order that is being paid for. * @return array The shipping data to send to Stripe. */ public function get_billing_data_from_order( WC_Order $order ): array { - return [ - 'name' => implode( - ' ', - array_filter( - [ - $order->get_billing_first_name(), - $order->get_billing_last_name(), - ] - ) - ), - 'address' => [ - 'line1' => $order->get_billing_address_1(), - 'line2' => $order->get_billing_address_2(), - 'postal_code' => $order->get_billing_postcode(), - 'city' => $order->get_billing_city(), - 'state' => $order->get_billing_state(), - 'country' => $order->get_billing_country(), - ], + $billing_fields = array_keys( WC()->countries->get_address_fields( $order->get_billing_country() ) ); + $address_field_to_key = [ + 'billing_city' => 'city', + 'billing_country' => 'country', + 'billing_address_1' => 'line1', + 'billing_address_2' => 'line2', + 'billing_postcode' => 'postal_code', + 'billing_state' => 'state', ]; + $field_to_key = [ + 'billing_email' => 'email', + 'billing_phone' => 'phone', + ]; + $billing_details = [ 'address' => [] ]; + foreach ( $billing_fields as $field ) { + if ( isset( $address_field_to_key[ $field ] ) ) { + $billing_details['address'][ $address_field_to_key[ $field ] ] = $order->{"get_{$field}"}(); + } elseif ( isset( $field_to_key[ $field ] ) ) { + $billing_details[ $field_to_key[ $field ] ] = $order->{"get_{$field}"}(); + } + } + + if ( in_array( 'billing_first_name', $billing_fields, true ) && in_array( 'billing_last_name', $billing_fields, true ) ) { + $billing_details['name'] = trim( $order->get_formatted_billing_full_name() ); + } + + return $billing_details; } /** diff --git a/includes/class-wc-payments-order-success-page.php b/includes/class-wc-payments-order-success-page.php index 7b69650a5c0..f4aca6ebccb 100644 --- a/includes/class-wc-payments-order-success-page.php +++ b/includes/class-wc-payments-order-success-page.php @@ -42,7 +42,7 @@ public function unregister_payment_method_override() { * Hooked into `woocommerce_order_get_payment_method_title` to change the payment method title on the * order received page for WooPay and BNPL orders. * - * @param string $payment_method_title Original payment method title. + * @param string $payment_method_title Original payment method title. * @param WC_Abstract_Order $abstract_order Successful received order being shown. * @return string */ @@ -82,7 +82,7 @@ public function show_woocommerce_payments_payment_method_name( $payment_method_t if ( $payment_method->is_bnpl() ) { $bnpl_output = $this->show_bnpl_payment_method_name( $gateway, $payment_method ); - if ( $bnpl_output !== false ) { + if ( false !== $bnpl_output ) { return $bnpl_output; } } @@ -117,7 +117,7 @@ public function show_woopay_payment_method_name( $order ) { /** * Add the BNPL logo to the payment method name on the order received page. * - * @param WC_Payment_Gateway_WCPay $gateway the gateway being shown. + * @param WC_Payment_Gateway_WCPay $gateway the gateway being shown. * @param WCPay\Payment_Methods\UPE_Payment_Method $payment_method the payment method being shown. * * @return string|false @@ -182,7 +182,7 @@ public function add_notice_previous_successful_intent( $text ) { * Formats the additional text to be displayed on the thank you page, with the side effect * as a workaround for an issue in Woo core 8.1.x and 8.2.x. * - * @param string $additional_text + * @param string $additional_text The additional text to be displayed. * * @return string Formatted text. */ @@ -196,7 +196,7 @@ private function format_addtional_thankyou_order_received_text( string $addition * @see https://github.com/woocommerce/woocommerce/pull/39758 Introduce the issue since 8.1.0. * @see https://github.com/woocommerce/woocommerce/pull/40353 Fix the issue since 8.3.0. */ - if( version_compare( WC_VERSION, '8.0', '>' ) + if ( version_compare( WC_VERSION, '8.0', '>' ) && version_compare( WC_VERSION, '8.3', '<' ) ) { echo " diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index d4f8c568a87..f89fdf39e2e 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -70,6 +70,10 @@ public function init() { return; } + if ( WC_Payments_Features::is_stripe_ece_enabled() ) { + return; + } + // Checks if Payment Request is enabled. if ( 'yes' !== $this->gateway->get_option( 'payment_request' ) ) { return; @@ -104,6 +108,150 @@ 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_pre_dispatch', [ $this, 'tokenized_cart_store_api_nonce_overwrite' ], 10, 3 ); + add_filter( + 'rest_post_dispatch', + [ $this, 'tokenized_cart_store_api_nonce_headers' ], + 10, + 3 + ); + } + } + + /** + * The nonce supplied by the frontend can be overwritten in this middleware: + * https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/assets/js/middleware/store-api-nonce.js + * + * This is a workaround to use instead a different nonce key, when supplied. + * + * @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_nonce_overwrite( $response, $server, $request ) { + $nonce = $request->get_header( 'X-WooPayments-Store-Api-Nonce' ); + if ( empty( $nonce ) ) { + return $response; + } + + $request->set_header( 'Nonce', $nonce ); + + return $response; + } + + /** + * 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; } /** @@ -433,10 +581,14 @@ public function get_cart_data() { * @param string $id Gateway ID. */ public function filter_gateway_title( $title, $id ) { + if ( 'woocommerce_payments' !== $id || ! is_admin() ) { + return $title; + } + $order = $this->get_current_order(); $method_title = is_object( $order ) ? $order->get_payment_method_title() : ''; - if ( 'woocommerce_payments' === $id && ! empty( $method_title ) ) { + if ( ! empty( $method_title ) ) { if ( strpos( $method_title, 'Apple Pay' ) === 0 || strpos( $method_title, 'Google Pay' ) === 0 @@ -482,12 +634,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; @@ -496,7 +648,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 */ @@ -531,7 +683,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; } @@ -563,14 +715,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; } @@ -587,6 +741,7 @@ public function should_show_payment_request_button() { ) { Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); + return false; } @@ -637,10 +792,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; @@ -676,7 +831,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; } @@ -689,6 +846,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 ) { @@ -729,6 +887,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() ), @@ -739,23 +898,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 ); @@ -791,30 +973,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; } /** @@ -880,7 +1082,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. @@ -1119,6 +1321,7 @@ public function ajax_pay_for_order() { 'messages' => __( 'Invalid request', 'woocommerce-payments' ), ]; wp_send_json( $response, 400 ); + return; } @@ -1212,7 +1415,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. @@ -1244,7 +1447,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. @@ -1269,7 +1472,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. @@ -1477,7 +1680,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() ); @@ -1492,7 +1696,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/class-wc-payments-status.php b/includes/class-wc-payments-status.php index da480abe413..f0e51a2a4f6 100644 --- a/includes/class-wc-payments-status.php +++ b/includes/class-wc-payments-status.php @@ -240,11 +240,6 @@ function ( $rule ) { - - : - - - : diff --git a/includes/class-wc-payments-utils.php b/includes/class-wc-payments-utils.php index 44eea0da051..f50a48b80d2 100644 --- a/includes/class-wc-payments-utils.php +++ b/includes/class-wc-payments-utils.php @@ -33,6 +33,25 @@ class WC_Payments_Utils { */ const FORCE_DISCONNECTED_FLAG_NAME = 'wcpaydev_force_disconnected'; + /** + * The Store API route patterns that should be handled by the WooPay session handler. + */ + const STORE_API_ROUTE_PATTERNS = [ + '@^\/wc\/store(\/v[\d]+)?\/cart$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/apply-coupon$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/remove-coupon$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/select-shipping-rate$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/update-customer$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/update-item$@', + '@^\/wc\/store(\/v[\d]+)?\/cart\/extensions$@', + '@^\/wc\/store(\/v[\d]+)?\/checkout\/(?P[\d]+)@', + '@^\/wc\/store(\/v[\d]+)?\/checkout$@', + '@^\/wc\/store(\/v[\d]+)?\/order\/(?P[\d]+)@', + // The route below is not a Store API route. However, this REST endpoint is used by WooPay to indirectly reach the Store API. + // By adding it to this list, we're able to identify the user and load the correct session for this route. + '@^\/wc\/v3\/woopay\/session$@', + ]; + /** * Mirrors JS's createInterpolateElement functionality. * Returns a string where angle brackets expressions are replaced with unescaped html while the rest is escaped. @@ -337,32 +356,6 @@ public static function map_search_orders_to_charge_ids( $search ) { return $terms; } - /** - * Extract the billing details from the WC order - * - * @param WC_Order $order Order to extract the billing details from. - * - * @return array - */ - public static function get_billing_details_from_order( $order ) { - $billing_details = [ - 'address' => [ - 'city' => $order->get_billing_city(), - 'country' => $order->get_billing_country(), - 'line1' => $order->get_billing_address_1(), - 'line2' => $order->get_billing_address_2(), - 'postal_code' => $order->get_billing_postcode(), - 'state' => $order->get_billing_state(), - ], - 'email' => $order->get_billing_email(), - 'name' => trim( $order->get_formatted_billing_full_name() ), - 'phone' => $order->get_billing_phone(), - ]; - - $billing_details['address'] = array_filter( $billing_details['address'] ); - return array_filter( $billing_details ); - } - /** * Redacts the provided array, removing the sensitive information, and limits its depth to LOG_MAX_RECURSION. * @@ -883,27 +876,6 @@ public static function format_explicit_currency( return $formatted_amount; } - /** - * Encrypts client secret of intents created on Stripe. - * - * @param string $stripe_account_id Stripe account ID. - * @param string $client_secret Client secret string. - * - * @return string Encrypted value. - */ - public static function encrypt_client_secret( string $stripe_account_id, string $client_secret ): string { - if ( \WC_Payments_Features::is_client_secret_encryption_enabled() ) { - return openssl_encrypt( - $client_secret, - 'aes-128-cbc', - substr( $stripe_account_id, 5 ), - 0, - str_repeat( 'WC', 8 ) - ); - } - return $client_secret; - } - /** * Checks if the HPOS order tables are being used. * @@ -1099,6 +1071,30 @@ public static function is_cart_block(): bool { return has_block( 'woocommerce/cart' ) || ( wp_is_block_theme() && is_cart() ); } + /** + * Returns true if the request that's currently being processed is a Store API request, false + * otherwise. + * + * @return bool True if request is a Store API request, false otherwise. + */ + public static function is_store_api_request(): bool { + if ( isset( $_REQUEST['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + $rest_route = sanitize_text_field( $_REQUEST['rest_route'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.NonceVerification + } else { + $url_parts = wp_parse_url( esc_url_raw( $_SERVER['REQUEST_URI'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash + $request_path = rtrim( $url_parts['path'], '/' ); + $rest_route = str_replace( trailingslashit( rest_get_url_prefix() ), '', $request_path ); + } + + foreach ( self::STORE_API_ROUTE_PATTERNS as $pattern ) { + if ( 1 === preg_match( $pattern, $rest_route ) ) { + return true; + } + } + + return false; + } + /** * Gets the current active theme transient for a given location * Falls back to 'stripe' if no transients are set. diff --git a/includes/class-wc-payments-woopay-direct-checkout.php b/includes/class-wc-payments-woopay-direct-checkout.php index 4132801186b..1cf3fbd35cb 100644 --- a/includes/class-wc-payments-woopay-direct-checkout.php +++ b/includes/class-wc-payments-woopay-direct-checkout.php @@ -20,14 +20,17 @@ class WC_Payments_WooPay_Direct_Checkout { * @return void */ public function init() { - add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + add_action( 'wp_footer', [ $this, 'scripts' ] ); add_filter( 'woocommerce_create_order', [ $this, 'maybe_use_store_api_draft_order_id' ] ); } /** * This filter is used to ensure the session's store_api_draft_order is used, if it exists. * This prevents a bug where the store_api_draft_order is not used and instead, a new - * order_awaiting_payment is created during the checkout request. + * order_awaiting_payment is created during the checkout request. The bug being evident + * if a product had one remaining stock and the store_api_draft_order was reserving it, + * an order would fail to be placed since when order_awaiting_payment is created, it would + * not be able to reserve the one stock. * * @param int $order_id The order ID being used. * @return int|mixed The new order ID to use. @@ -39,11 +42,23 @@ public function maybe_use_store_api_draft_order_id( $order_id ) { $is_already_defined_order_id = ! empty( $order_id ); // Only apply this filter if the session doesn't already have an order_awaiting_payment. $is_order_awaiting_payment = isset( WC()->session->order_awaiting_payment ); - if ( ! $is_checkout || $is_already_defined_order_id || $is_order_awaiting_payment ) { + // Only apply this filter if draft order ID exists. + $has_draft_order = ! empty( WC()->session->get( 'store_api_draft_order' ) ); + if ( ! $is_checkout || $is_already_defined_order_id || $is_order_awaiting_payment || ! $has_draft_order ) { return $order_id; } - return absint( WC()->session->get( 'store_api_draft_order', $order_id ) ); + $draft_order_id = absint( WC()->session->get( 'store_api_draft_order' ) ); + // Set the order status to "pending" payment, so that it can be resumed. + $draft_order = wc_get_order( $draft_order_id ); + $draft_order->set_status( 'pending' ); + $draft_order->save(); + + // Move $draft_order_id in session, from store_api_draft_order to order_awaiting_payment. + WC()->session->set( 'store_api_draft_order', null ); + WC()->session->set( 'order_awaiting_payment', $draft_order_id ); + + return $order_id; } /** @@ -52,8 +67,7 @@ public function maybe_use_store_api_draft_order_id( $order_id ) { * @return void */ public function scripts() { - // Only enqueue the script on the cart page, for now. - if ( ! $this->is_cart_page() ) { + if ( ! $this->should_enqueue_scripts() ) { return; } @@ -73,12 +87,26 @@ public function scripts() { wp_enqueue_script( 'WCPAY_WOOPAY_DIRECT_CHECKOUT' ); } + /** + * Check if the direct checkout scripts should be enqueued on the page. + * + * Scripts should be enqueued if: + * - The current page is the cart page. + * - The current page has a cart block. + * - The current page has the blocks mini cart widget, i.e 'woocommerce_blocks_cart_enqueue_data' has been fired. + * + * @return bool True if the scripts should be enqueued, false otherwise. + */ + private function should_enqueue_scripts(): bool { + return $this->is_cart_page() || did_action( 'woocommerce_blocks_cart_enqueue_data' ) > 0; + } + /** * Check if the current page is the cart page. * * @return bool True if the current page is the cart page, false otherwise. */ - public function is_cart_page(): bool { + private function is_cart_page(): bool { return is_cart() || has_block( 'woocommerce/cart' ); } @@ -87,7 +115,7 @@ public function is_cart_page(): bool { * * @return bool True if the current page is the product page, false otherwise. */ - public function is_product_page() { + private function is_product_page() { return is_product() || wc_post_content_has_shortcode( 'product_page' ); } } diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index dc82d7a4304..63e8b079157 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -411,6 +411,7 @@ public static function init() { include_once __DIR__ . '/class-wc-payments-status.php'; include_once __DIR__ . '/class-wc-payments-token-service.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-display-handler.php'; + include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-handler.php'; include_once __DIR__ . '/class-wc-payments-payment-request-button-handler.php'; include_once __DIR__ . '/class-wc-payments-woopay-button-handler.php'; include_once __DIR__ . '/class-wc-payments-woopay-direct-checkout.php'; @@ -422,10 +423,13 @@ public static function init() { include_once __DIR__ . '/exceptions/class-intent-authentication-exception.php'; include_once __DIR__ . '/exceptions/class-invalid-payment-method-exception.php'; include_once __DIR__ . '/exceptions/class-process-payment-exception.php'; + include_once __DIR__ . '/exceptions/class-invalid-phone-number-exception.php'; include_once __DIR__ . '/exceptions/class-invalid-webhook-data-exception.php'; include_once __DIR__ . '/exceptions/class-invalid-price-exception.php'; include_once __DIR__ . '/exceptions/class-fraud-ruleset-exception.php'; + include_once __DIR__ . '/exceptions/class-fraud-prevention-enabled-exception.php'; include_once __DIR__ . '/exceptions/class-order-not-found-exception.php'; + include_once __DIR__ . '/exceptions/class-rate-limiter-enabled-exception.php'; include_once __DIR__ . '/constants/class-base-constant.php'; include_once __DIR__ . '/constants/class-country-code.php'; include_once __DIR__ . '/constants/class-currency-code.php'; @@ -489,7 +493,7 @@ public static function init() { self::$action_scheduler_service = new WC_Payments_Action_Scheduler_Service( self::$api_client, self::$order_service ); self::$session_service = new WC_Payments_Session_Service( self::$api_client ); self::$account = new WC_Payments_Account( self::$api_client, self::$database_cache, self::$action_scheduler_service, self::$session_service ); - self::$customer_service = new WC_Payments_Customer_Service( self::$api_client, self::$account, self::$database_cache, self::$session_service ); + self::$customer_service = new WC_Payments_Customer_Service( self::$api_client, self::$account, self::$database_cache, self::$session_service, self::$order_service ); self::$token_service = new WC_Payments_Token_Service( self::$api_client, self::$customer_service ); self::$remote_note_service = new WC_Payments_Remote_Note_Service( WC_Data_Store::load( 'admin-note' ) ); self::$fraud_service = new WC_Payments_Fraud_Service( self::$api_client, self::$customer_service, self::$account, self::$session_service, self::$database_cache ); @@ -964,6 +968,13 @@ private static function get_wc_payments_http() { * Initialize the REST API controllers. */ public static function init_rest_api() { + // Ensures we are not initializing our REST during `rest_preload_api_request`. + // When constructors signature changes, in manual update scenarios we were run into fatals. + // Those fatals are not critical, but it causes hickups in release process as catches unnecessary attention. + if ( function_exists( 'get_current_screen' ) && get_current_screen() ) { + return; + } + include_once WCPAY_ABSPATH . 'includes/exceptions/class-rest-request-exception.php'; include_once WCPAY_ABSPATH . 'includes/admin/class-wc-payments-rest-controller.php'; @@ -1617,7 +1628,8 @@ public static function maybe_display_express_checkout_buttons() { if ( WC_Payments_Features::are_payments_enabled() ) { $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() ); - $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, self::get_express_checkout_helper() ); + $express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); + $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, self::get_express_checkout_helper() ); $express_checkout_button_display_handler->init(); } } diff --git a/includes/class-woopay-tracker.php b/includes/class-woopay-tracker.php index 511aeafc5cd..86a00a3ab08 100644 --- a/includes/class-woopay-tracker.php +++ b/includes/class-woopay-tracker.php @@ -13,7 +13,6 @@ use WC_Payments_Features; use WCPay\Constants\Country_Code; use WP_Error; -use Exception; defined( 'ABSPATH' ) || exit; // block direct access. @@ -128,13 +127,13 @@ public function ajax_tracks_id() { /** * Generic method to track user events on WooPay enabled stores. * - * @param string $event name of the event. - * @param array $data array of event properties. + * @param string $event name of the event. + * @param array $data array of event properties. */ public function maybe_record_event( $event, $data = [] ) { // Top level events should not be namespaced. if ( '_aliasUser' !== $event ) { - $event = self::$user_prefix . '_' . $event; + $event = self::$user_prefix . '_' . $event; } return $this->tracks_record_event( $event, $data ); @@ -152,7 +151,10 @@ public function maybe_record_wcpay_shopper_event( $event, $data = [] ) { $event = self::$user_prefix . '_' . $event; } - return $this->tracks_record_event( $event, $data ); + $is_admin_event = false; + $track_on_all_stores = true; + + return $this->tracks_record_event( $event, $data, $is_admin_event, $track_on_all_stores ); } /** @@ -190,11 +192,12 @@ public function is_country_tracks_eligible() { /** * Override parent method to omit the jetpack TOS check and include custom tracking conditions. * - * @param bool $is_admin_event Indicate whether the event is emitted from admin area. + * @param bool $is_admin_event Indicate whether the event is emitted from admin area. + * @param bool $track_on_all_stores Indicate whether the event should be tracked on all stores. * * @return bool */ - public function should_enable_tracking( $is_admin_event = false ) { + public function should_enable_tracking( $is_admin_event = false, $track_on_all_stores = false ) { // Don't track if the gateway is not enabled. $gateway = \WC_Payments::get_gateway(); @@ -226,7 +229,8 @@ public function should_enable_tracking( $is_admin_event = false ) { // For all other events ensure: // 1. Only site pages are tracked. // 2. Site Admin activity in site pages are not tracked. - // 3. Otherwise, track only when WooPay is active. + // 3. If track_on_all_stores is enabled, track all events regardless of WooPay eligibility. + // 4. Otherwise, track only when WooPay is active. // Track only site pages. if ( is_admin() && ! wp_doing_ajax() ) { @@ -238,6 +242,10 @@ public function should_enable_tracking( $is_admin_event = false ) { return false; } + if ( $track_on_all_stores ) { + return true; + } + // For the remaining events, don't track when woopay is disabled. $is_woopay_eligible = WC_Payments_Features::is_woopay_eligible(); // Feature flag. $is_woopay_enabled = 'yes' === $gateway->get_option( 'platform_checkout', 'no' ); @@ -254,10 +262,11 @@ public function should_enable_tracking( $is_admin_event = false ) { * @param string $event_name The name of the event. * @param array $properties Custom properties to send with the event. * @param bool $is_admin_event Indicate whether the event is emitted from admin area. + * @param bool $track_on_all_stores Indicate whether the event should be tracked on all stores. * * @return bool|array|\WP_Error|\Jetpack_Tracks_Event */ - public function tracks_record_event( $event_name, $properties = [], $is_admin_event = false ) { + public function tracks_record_event( $event_name, $properties = [], $is_admin_event = false, $track_on_all_stores = false ) { $user = wp_get_current_user(); @@ -266,7 +275,7 @@ public function tracks_record_event( $event_name, $properties = [], $is_admin_ev return false; } - if ( ! $this->should_enable_tracking( $is_admin_event ) ) { + if ( ! $this->should_enable_tracking( $is_admin_event, $track_on_all_stores ) ) { return false; } @@ -298,7 +307,7 @@ private function tracks_build_event_obj( $user, $event_name, $properties = [] ) $identity = $this->tracks_get_identity(); $site_url = get_option( 'siteurl' ); - $properties['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ): ''; + $properties['_lg'] = isset( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) : ''; $properties['blog_url'] = $site_url; $properties['blog_id'] = \Jetpack_Options::get_option( 'id' ); $properties['user_lang'] = $user->get( 'WPLANG' ); @@ -308,7 +317,7 @@ private function tracks_build_event_obj( $user, $event_name, $properties = [] ) $properties['wcpay_version'] = WCPAY_VERSION_NUMBER; // Add client's user agent to the event properties. - if ( !empty( $_SERVER['HTTP_USER_AGENT'] ) ) { + if ( ! empty( $_SERVER['HTTP_USER_AGENT'] ) ) { $properties['_via_ua'] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ); } @@ -343,7 +352,7 @@ private function tracks_build_event_obj( $user, $event_name, $properties = [] ) * @return array $identity */ public function tracks_get_identity() { - $user_id = get_current_user_id(); + $user_id = get_current_user_id(); // Meta is set, and user is still connected. Use WPCOM ID. $wpcom_id = get_user_meta( $user_id, 'jetpack_tracks_wpcom_id', true ); @@ -460,6 +469,17 @@ public function pay_for_order_page_view() { * @return bool */ public function bump_stats( $group, $stat_name ) { + $is_admin_event = false; + $track_on_all_stores = true; + + if ( ! $this->should_enable_tracking( $is_admin_event, $track_on_all_stores ) ) { + return false; + } + + if ( WC_Payments::mode()->is_test() ) { + return false; + } + $pixel_url = sprintf( self::$pixel_base_url . '?v=wpcom-no-pv&x_%s=%s', $group, @@ -481,17 +501,19 @@ public function bump_stats( $group, $stat_name ) { /** * Record that the order has been processed. + * + * @param int $order_id The ID of the order. */ public function checkout_order_processed( $order_id ) { $payment_gateway = wc_get_payment_gateway_by_order( $order_id ); - $properties = [ 'payment_title' => 'other' ]; + $properties = [ 'payment_title' => 'other' ]; // If the order was placed using WooCommerce Payments, record the payment title using Tracks. - if (strpos( $payment_gateway->id, 'woocommerce_payments') === 0 ) { - $order = wc_get_order( $order_id ); + if ( strpos( $payment_gateway->id, 'woocommerce_payments' ) === 0 ) { + $order = wc_get_order( $order_id ); $payment_title = $order->get_payment_method_title(); - $properties = [ 'payment_title' => $payment_title ]; + $properties = [ 'payment_title' => $payment_title ]; $is_woopay_order = ( isset( $_SERVER['HTTP_USER_AGENT'] ) && 'WooPay' === $_SERVER['HTTP_USER_AGENT'] ); @@ -499,7 +521,7 @@ public function checkout_order_processed( $order_id ) { if ( ! $is_woopay_order ) { $this->maybe_record_wcpay_shopper_event( 'checkout_order_placed', $properties ); } - // If the order was placed using a different payment gateway, just increment a counter. + // If the order was placed using a different payment gateway, just increment a counter. } else { $this->bump_stats( 'wcpay_order_completed_gateway', 'other' ); } @@ -523,7 +545,7 @@ public function must_save_payment_method_to_platform() { * @param int $order_id The ID of the order. * @return void */ - public function thank_you_page_view($order_id) { + public function thank_you_page_view( $order_id ) { $order = wc_get_order( $order_id ); if ( ! $order || 'woocommerce_payments' !== $order->get_payment_method() ) { diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index e4bbb2d3458..09c1ac59c86 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -264,7 +264,7 @@ public function prepare_intent_for_order_pay_page(): bool { } $js_config = WC_Payments::get_wc_payments_checkout()->get_payment_fields_js_config(); - $js_config['intentSecret'] = WC_Payments_Utils::encrypt_client_secret( $intent->get_stripe_account_id(), $intent->get_client_secret() ); + $js_config['intentSecret'] = $intent->get_client_secret(); $js_config['updateOrderNonce'] = wp_create_nonce( 'wcpay_update_order_status_nonce' ); wp_localize_script( 'WCPAY_CHECKOUT', 'wcpayConfig', $js_config ); wp_enqueue_script( 'WCPAY_CHECKOUT' ); @@ -971,7 +971,6 @@ public function maybe_add_customer_notification_note( WC_Order $order, array $pr $order->add_order_note( $note ); } - } /** diff --git a/includes/core/server/request/class-add-account-tos-agreement.md b/includes/core/server/request/class-add-account-tos-agreement.md index 5b97bee0993..b25dc68ede2 100644 --- a/includes/core/server/request/class-add-account-tos-agreement.md +++ b/includes/core/server/request/class-add-account-tos-agreement.md @@ -1,6 +1,6 @@ # `Add_Account_Tos_Agreement` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-cancel-intention.md b/includes/core/server/request/class-cancel-intention.md index 0f22a295fae..9c4c1e449e5 100644 --- a/includes/core/server/request/class-cancel-intention.md +++ b/includes/core/server/request/class-cancel-intention.md @@ -1,6 +1,6 @@ # `Cancel_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-capture-intention.md b/includes/core/server/request/class-capture-intention.md index 785b9012bd9..0b217e0cbe8 100644 --- a/includes/core/server/request/class-capture-intention.md +++ b/includes/core/server/request/class-capture-intention.md @@ -1,6 +1,6 @@ # `Capture_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-and-confirm-intention.md b/includes/core/server/request/class-create-and-confirm-intention.md index e6e2c390f00..809c2af99a6 100644 --- a/includes/core/server/request/class-create-and-confirm-intention.md +++ b/includes/core/server/request/class-create-and-confirm-intention.md @@ -1,6 +1,6 @@ # `Create_and_Confirm_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-and-confirm-intention.php b/includes/core/server/request/class-create-and-confirm-intention.php index 905354045d1..ddf94e9e1e0 100644 --- a/includes/core/server/request/class-create-and-confirm-intention.php +++ b/includes/core/server/request/class-create-and-confirm-intention.php @@ -20,6 +20,7 @@ class Create_And_Confirm_Intention extends Create_Intention { 'amount', 'currency', 'payment_method', + 'payment_method_update_data', 'return_url', ]; @@ -90,6 +91,17 @@ public function set_payment_methods( array $payment_methods ) { $this->set_param( 'payment_method_types', $payment_methods ); } + /** + * Payment method update data setter. + * + * @param array $payment_method_update_data Data to update on payment method. + * + * @return void + */ + public function set_payment_method_update_data( array $payment_method_update_data ) { + $this->set_param( 'payment_method_update_data', $payment_method_update_data ); + } + /** * CVC confirmation setter. * diff --git a/includes/core/server/request/class-create-and-confirm-setup-intention.md b/includes/core/server/request/class-create-and-confirm-setup-intention.md index e50bf1fd471..e1a4de75f80 100644 --- a/includes/core/server/request/class-create-and-confirm-setup-intention.md +++ b/includes/core/server/request/class-create-and-confirm-setup-intention.md @@ -1,6 +1,6 @@ # `Create_and_Confirm_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-intention.md b/includes/core/server/request/class-create-intention.md index 0ac4f7c397b..fc7c16fbf60 100644 --- a/includes/core/server/request/class-create-intention.md +++ b/includes/core/server/request/class-create-intention.md @@ -1,6 +1,6 @@ # `Create_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-setup-intention.md b/includes/core/server/request/class-create-setup-intention.md index 877ec791a65..a8aae0547ff 100644 --- a/includes/core/server/request/class-create-setup-intention.md +++ b/includes/core/server/request/class-create-setup-intention.md @@ -1,6 +1,6 @@ # `Create_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account-capital-link.md b/includes/core/server/request/class-get-account-capital-link.md index 2360ba3a0a9..861fd5f6c30 100644 --- a/includes/core/server/request/class-get-account-capital-link.md +++ b/includes/core/server/request/class-get-account-capital-link.md @@ -1,6 +1,6 @@ # `Get_Account_Capital_Link` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account-login-data.md b/includes/core/server/request/class-get-account-login-data.md index 3f3675207e4..d5e306138e7 100644 --- a/includes/core/server/request/class-get-account-login-data.md +++ b/includes/core/server/request/class-get-account-login-data.md @@ -1,6 +1,6 @@ # `Get_Account_Login_Data` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account.md b/includes/core/server/request/class-get-account.md index 48606f63de9..ef329de1011 100644 --- a/includes/core/server/request/class-get-account.md +++ b/includes/core/server/request/class-get-account.md @@ -1,6 +1,6 @@ # `Get_Account` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md). +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md). ## Description diff --git a/includes/core/server/request/class-get-charge.md b/includes/core/server/request/class-get-charge.md index bc9173a41e1..adecfb612e2 100644 --- a/includes/core/server/request/class-get-charge.md +++ b/includes/core/server/request/class-get-charge.md @@ -1,6 +1,6 @@ # `Get_Charge` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-intention.md b/includes/core/server/request/class-get-intention.md index f7b71e8c091..90331d78c43 100644 --- a/includes/core/server/request/class-get-intention.md +++ b/includes/core/server/request/class-get-intention.md @@ -1,6 +1,6 @@ # `Get_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-reporting-payment-activity.md b/includes/core/server/request/class-get-reporting-payment-activity.md new file mode 100644 index 00000000000..53f676f6d59 --- /dev/null +++ b/includes/core/server/request/class-get-reporting-payment-activity.md @@ -0,0 +1,38 @@ +# `Get_Reporting_Payment_Activity` request class + +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) + +## Description + +The `WCPay\Core\Server\Request\Get_Reporting_Payment_Activity` class is used to construct the request for retrieving payment activity. + +## Parameters + +| Parameter | Setter | Immutable | Required | Default value | +|-------------|-------------------------------------------|:---------:|:--------:|:-------------:| +| `date_start`| `set_date_start( string $date_start )` | No | Yes | - | +| `date_end` | `set_date_end( string $date_end )` | No | Yes | - | +| `timezone` | `set_timezone( string $timezone )` | No | Yes | - | + +The `date_start` and `date_end` parameters should be in the 'YYYY-MM-DDT00:00:00' format. +The `timezone` parameter can be passed as an offset or as a [timezone name](https://www.php.net/manual/en/timezones.php). + +## Filter + +When using this request, provide the following filter and arguments: + +- Name: `wcpay_get_payment_activity` + +## Example: + +```php +$request = Get_Reporting_Payment_Activity::create(); +$request->set_date_start( $date_start ); +$request->set_date_end( $date_end ); +$request->set_timezone( $timezone ); +$request->send(); +``` + +## Exceptions + +- `Invalid_Request_Parameter_Exception` - Thrown when the provided date or timezone is not in expected format. \ No newline at end of file diff --git a/includes/core/server/request/class-get-reporting-payment-activity.php b/includes/core/server/request/class-get-reporting-payment-activity.php index d9be6cf33eb..f8697a198e8 100644 --- a/includes/core/server/request/class-get-reporting-payment-activity.php +++ b/includes/core/server/request/class-get-reporting-payment-activity.php @@ -16,7 +16,6 @@ */ class Get_Reporting_Payment_Activity extends Request { - const REQUIRED_PARAMS = [ 'date_start', 'date_end', @@ -50,32 +49,52 @@ public function get_method(): string { /** * Sets the start date for the payment activity data. * - * @param string|null $date_start The start date in the format 'YYYY-MM-DDT00:00:00' or null. + * @param string $date_start The start date in the format 'YYYY-MM-DDT00:00:00'. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the date is not in valid format. */ - public function set_date_start( ?string $date_start ) { - // TBD - validation. + public function set_date_start( string $date_start ) { + $this->validate_date( $date_start, 'Y-m-d\TH:i:s' ); $this->set_param( 'date_start', $date_start ); } /** * Sets the end date for the payment activity data. * - * @param string|null $date_end The end date in the format 'YYYY-MM-DDT00:00:00' or null. + * @param string $date_end The end date in the format 'YYYY-MM-DDT00:00:00'. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the date is not in valid format. */ public function set_date_end( string $date_end ) { - // TBD - validation. + $this->validate_date( $date_end, 'Y-m-d\TH:i:s' ); $this->set_param( 'date_end', $date_end ); } /** * Sets the timezone for the reporting data. * - * @param string|null $timezone The timezone to set or null. + * @param string $timezone The timezone to set. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the timezone is not in valid format. */ - public function set_timezone( ?string $timezone ) { - $this->set_param( 'timezone', $timezone ?? 'UTC' ); + public function set_timezone( string $timezone ) { + try { + new \DateTimeZone( $timezone ); + } catch ( \Exception $e ) { + throw new Invalid_Request_Parameter_Exception( + esc_html( + sprintf( + // Translators: %s is a provided timezone. + __( '%s is not a valid timezone.', 'woocommerce-payments' ), + $timezone, + ) + ), + 'wcpay_core_invalid_request_parameter_invalid_timezone' + ); + } + $this->set_param( 'timezone', $timezone ); } } diff --git a/includes/core/server/request/class-get-request.md b/includes/core/server/request/class-get-request.md index b591cb98cba..b439ec2e612 100644 --- a/includes/core/server/request/class-get-request.md +++ b/includes/core/server/request/class-get-request.md @@ -1,7 +1,7 @@ # `Get_Request` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-setup-intention.md b/includes/core/server/request/class-get-setup-intention.md index 5b42c8ad4fe..ab3ed78e07e 100644 --- a/includes/core/server/request/class-get-setup-intention.md +++ b/includes/core/server/request/class-get-setup-intention.md @@ -1,6 +1,6 @@ # `Get_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-authorizations.md b/includes/core/server/request/class-list-authorizations.md index ba1b070ef65..5802476a3bd 100644 --- a/includes/core/server/request/class-list-authorizations.md +++ b/includes/core/server/request/class-list-authorizations.md @@ -1,6 +1,6 @@ # `List_Authorizations` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-charge-refunds.md b/includes/core/server/request/class-list-charge-refunds.md index 485f808d4af..4d9b73873a7 100644 --- a/includes/core/server/request/class-list-charge-refunds.md +++ b/includes/core/server/request/class-list-charge-refunds.md @@ -1,6 +1,6 @@ # `List_Charge_Refunds` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-deposits.md b/includes/core/server/request/class-list-deposits.md index 9e25eacc72c..e3560134f77 100644 --- a/includes/core/server/request/class-list-deposits.md +++ b/includes/core/server/request/class-list-deposits.md @@ -1,6 +1,6 @@ # `List_Deposits` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-disputes.md b/includes/core/server/request/class-list-disputes.md index 83e720fda2a..2b35a520e04 100644 --- a/includes/core/server/request/class-list-disputes.md +++ b/includes/core/server/request/class-list-disputes.md @@ -1,6 +1,6 @@ # `List_Disputes` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-documents.md b/includes/core/server/request/class-list-documents.md index 207a2eb00fd..17d06b911c3 100644 --- a/includes/core/server/request/class-list-documents.md +++ b/includes/core/server/request/class-list-documents.md @@ -1,6 +1,6 @@ # `List_Documents` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-fraud-outcome-transactions.md b/includes/core/server/request/class-list-fraud-outcome-transactions.md index 7e7b48d49f0..2c24e506414 100644 --- a/includes/core/server/request/class-list-fraud-outcome-transactions.md +++ b/includes/core/server/request/class-list-fraud-outcome-transactions.md @@ -1,6 +1,6 @@ # `List_Fraud_Outcome_Transactions` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-transactions.md b/includes/core/server/request/class-list-transactions.md index d791bcc322d..9c5bbbeaff2 100644 --- a/includes/core/server/request/class-list-transactions.md +++ b/includes/core/server/request/class-list-transactions.md @@ -1,6 +1,6 @@ # `List_Transactions` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-refund-charge.md b/includes/core/server/request/class-refund-charge.md index 7ea32059739..36610499e43 100644 --- a/includes/core/server/request/class-refund-charge.md +++ b/includes/core/server/request/class-refund-charge.md @@ -1,6 +1,6 @@ # `Refund_Charge` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-update-account.md b/includes/core/server/request/class-update-account.md index 759fed59df2..ac44ba13b62 100644 --- a/includes/core/server/request/class-update-account.md +++ b/includes/core/server/request/class-update-account.md @@ -1,6 +1,6 @@ # `Update_Account` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-update-intention.md b/includes/core/server/request/class-update-intention.md index cdc37f87aca..2a28b99e342 100644 --- a/includes/core/server/request/class-update-intention.md +++ b/includes/core/server/request/class-update-intention.md @@ -1,6 +1,6 @@ # `Update_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-and-confirm-intention.md b/includes/core/server/request/class-woopay-create-and-confirm-intention.md index 225cf21e01c..2b307abba5a 100644 --- a/includes/core/server/request/class-woopay-create-and-confirm-intention.md +++ b/includes/core/server/request/class-woopay-create-and-confirm-intention.md @@ -1,6 +1,6 @@ # `WooPay_Create_and_Confirm_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md b/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md index 04904419e1a..0e0ca45cee9 100644 --- a/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md +++ b/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md @@ -1,6 +1,6 @@ # `WooPay_Create_and_Confirm_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-intent.md b/includes/core/server/request/class-woopay-create-intent.md index 0ae42ef421f..6e4d4314a44 100644 --- a/includes/core/server/request/class-woopay-create-intent.md +++ b/includes/core/server/request/class-woopay-create-intent.md @@ -1,6 +1,6 @@ # `WooPay_Create_Intent` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/emails/class-wc-payments-email-ipp-receipt.php b/includes/emails/class-wc-payments-email-ipp-receipt.php index 30804650f0c..6e51eed4016 100644 --- a/includes/emails/class-wc-payments-email-ipp-receipt.php +++ b/includes/emails/class-wc-payments-email-ipp-receipt.php @@ -246,7 +246,6 @@ public function compliance_details( array $charge, bool $plain_text ) { public function get_default_additional_content(): string { return __( 'Thanks for using {site_url}!', 'woocommerce-payments' ); } - } endif; diff --git a/includes/exceptions/class-fraud-prevention-enabled-exception.php b/includes/exceptions/class-fraud-prevention-enabled-exception.php new file mode 100644 index 00000000000..c6a2e0da10a --- /dev/null +++ b/includes/exceptions/class-fraud-prevention-enabled-exception.php @@ -0,0 +1,16 @@ +gateway = $gateway; $this->payment_request_button_handler = $payment_request_button_handler; $this->platform_checkout_button_handler = $platform_checkout_button_handler; + $this->express_checkout_button_handler = $express_checkout_button_handler; $this->express_checkout_helper = $express_checkout_helper; } @@ -65,6 +80,7 @@ public function __construct( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Paym public function init() { $this->platform_checkout_button_handler->init(); $this->payment_request_button_handler->init(); + $this->express_checkout_button_handler->init(); $is_woopay_enabled = WC_Payments_Features::is_woopay_enabled(); $is_payment_request_enabled = 'yes' === $this->gateway->get_option( 'payment_request' ); @@ -116,8 +132,14 @@ public function display_express_checkout_buttons() { if ( ! $this->express_checkout_helper->is_pay_for_order_page() || $this->is_pay_for_order_flow_supported() ) { $this->platform_checkout_button_handler->display_woopay_button_html(); } + + if ( WC_Payments_Features::is_stripe_ece_enabled() ) { + $this->express_checkout_button_handler->display_express_checkout_button_html(); + } else { $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-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php new file mode 100644 index 00000000000..9cf4ed5a71f --- /dev/null +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php @@ -0,0 +1,554 @@ +account = $account; + $this->gateway = $gateway; + $this->express_checkout_helper = $express_checkout_helper; + } + + /** + * Initialize hooks. + * + * @return void + */ + public function init() { + // Checks if WCPay is enabled. + if ( ! $this->gateway->is_enabled() ) { + return; + } + + if ( ! WC_Payments_Features::is_stripe_ece_enabled() ) { + return; + } + + // Checks if Payment Request is enabled. + if ( 'yes' !== $this->gateway->get_option( 'payment_request' ) ) { + return; + } + + // Don't load for change payment method page. + if ( isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + return; + } + + add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + } + + /** + * The settings for the `button` attribute - they depend on the "grouped settings" flag value. + * + * @return array + */ + public function get_button_settings() { + $button_type = $this->gateway->get_option( 'payment_request_button_type' ); + $common_settings = $this->express_checkout_helper->get_common_button_settings(); + $payment_request_button_settings = [ + // Default format is en_US. + 'locale' => apply_filters( 'wcpay_payment_request_button_locale', substr( get_locale(), 0, 2 ) ), + 'branded_type' => 'default' === $button_type ? 'short' : 'long', + ]; + + return array_merge( $common_settings, $payment_request_button_settings ); + } + + /** + * Checks whether Payment Request Button should be available on this page. + * + * @return bool + */ + public function should_show_express_checkout_button() { + // If account is not connected, then bail. + if ( ! $this->account->is_stripe_connected( false ) ) { + return false; + } + + // If no SSL, bail. + if ( ! WC_Payments::mode()->is_test() && ! is_ssl() ) { + Logger::log( 'Stripe Payment Request live mode requires SSL.' ); + + return false; + } + + // Page not supported. + if ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_checkout() ) { + return false; + } + + // Product page, but not available in settings. + if ( $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_available_at( 'product', self::BUTTON_LOCATIONS ) ) { + return false; + } + + // Checkout page, but not available in settings. + if ( $this->express_checkout_helper->is_checkout() && ! $this->express_checkout_helper->is_available_at( 'checkout', self::BUTTON_LOCATIONS ) ) { + return false; + } + + // Cart page, but not available in settings. + if ( $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_available_at( 'cart', self::BUTTON_LOCATIONS ) ) { + return false; + } + + // Product page, but has unsupported product type. + if ( $this->express_checkout_helper->is_product() && ! $this->is_product_supported() ) { + 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; + } + + // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. + if ( $this->express_checkout_helper->is_pay_for_order_page() ) { + return true; + } + + // Cart total is 0 or is on product page and product price is 0. + // Exclude pay-for-order pages from this check. + if ( + ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_pay_for_order_page() && 0.0 === (float) WC()->cart->get_total( 'edit' ) ) || + ( $this->express_checkout_helper->is_product() && 0.0 === (float) $this->express_checkout_helper->get_product()->get_price() ) + + ) { + Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); + return false; + } + + return true; + } + + /** + * Checks to make sure product type is supported. + * + * @return array + */ + public function supported_product_types() { + return apply_filters( + 'wcpay_payment_request_supported_types', + [ + 'simple', + 'variable', + 'variation', + 'subscription', + 'variable-subscription', + 'subscription_variation', + 'booking', + 'bundle', + 'composite', + 'mix-and-match', + ] + ); + } + + /** + * Checks the cart to see if all items are allowed to be used. + * + * @return boolean + * + * @psalm-suppress UndefinedClass + */ + public function has_allowed_items_in_cart() { + /** + * Pre Orders compatbility where we don't support charge upon release. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Pre_Orders_Cart' ) && WC_Pre_Orders_Cart::cart_contains_pre_order() && class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() ) ) { + return false; + } + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); + + if ( ! in_array( $_product->get_type(), $this->supported_product_types(), true ) ) { + return false; + } + + /** + * 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. + */ + if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { + return false; + } + + /** + * Trial subscriptions with shipping are not supported. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Subscriptions_Product' ) && WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $_product ) > 0 ) { + return false; + } + } + + // We don't support multiple packages with Payment Request Buttons because we can't offer a good UX. + $packages = WC()->cart->get_shipping_packages(); + if ( 1 < ( is_countable( $packages ) ? count( $packages ) : 0 ) ) { + return false; + } + + return true; + } + + /** + * Load public scripts and styles. + */ + public function scripts() { + // Don't load scripts if page is not supported. + if ( ! $this->should_show_express_checkout_button() ) { + return; + } + + $payment_request_params = [ + 'ajax_url' => admin_url( 'admin-ajax.php' ), + 'wc_ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), + 'stripe' => [ + 'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ), + 'accountId' => $this->account->get_stripe_account_id(), + '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' ), + ], + 'checkout' => [ + 'currency_code' => strtolower( get_woocommerce_currency() ), + 'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ), + 'needs_shipping' => WC()->cart->needs_shipping(), + // Defaults to 'required' to match how core initializes this option. + 'needs_payer_phone' => 'required' === get_option( 'woocommerce_checkout_phone_field', 'required' ), + ], + 'button' => $this->get_button_settings(), + 'login_confirmation' => '', + '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(), + 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), + ]; + + WC_Payments::register_script_with_dependencies( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'dist/express-checkout', [ 'jquery', 'stripe' ] ); + + WC_Payments_Utils::enqueue_style( + 'WCPAY_EXPRESS_CHECKOUT_ECE', + plugins_url( 'dist/payment-request.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/payment-request.css' ) + ); + + wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $payment_request_params ); + + wp_set_script_translations( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'woocommerce-payments' ); + + wp_enqueue_script( 'WCPAY_EXPRESS_CHECKOUT_ECE' ); + + Fraud_Prevention_Service::maybe_append_fraud_prevention_token(); + + $gateways = WC()->payment_gateways->get_available_payment_gateways(); + if ( isset( $gateways['woocommerce_payments'] ) ) { + WC_Payments::get_wc_payments_checkout()->register_scripts(); + } + } + + /** + * Display the payment request button. + */ + public function display_express_checkout_button_html() { + if ( ! $this->should_show_express_checkout_button() ) { + return; + } + ?> +
+ express_checkout_helper->get_product(); + $is_supported = true; + + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + 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' ) ) { + // 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 apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); + } + + /** + * Gets the product data for the currently viewed page. + * + * @return mixed Returns false if not on a product page, the product information otherwise. + */ + public function get_product_data() { + if ( ! $this->express_checkout_helper->is_product() ) { + return false; + } + + /** @var WC_Product_Variable $product */ // phpcs:ignore + $product = $this->express_checkout_helper->get_product(); + $currency = get_woocommerce_currency(); + + if ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) { + $variation_attributes = $product->get_variation_attributes(); + $attributes = []; + + foreach ( $variation_attributes as $attribute_name => $attribute_values ) { + $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); + + // Passed value via GET takes precedence. Otherwise get the default value for given attribute. + $attributes[ $attribute_key ] = isset( $_GET[ $attribute_key ] ) // phpcs:ignore WordPress.Security.NonceVerification + ? wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ) // phpcs:ignore WordPress.Security.NonceVerification + : $product->get_variation_default_attribute( $attribute_name ); + } + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + if ( ! empty( $variation_id ) ) { + $product = wc_get_product( $variation_id ); + } + } + + try { + $price = $this->get_product_price( $product ); + } catch ( Invalid_Price_Exception $e ) { + Logger::log( $e->getMessage() ); + return false; + } + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name(), + 'amount' => WC_Payments_Utils::prepare_amount( $price, $currency ), + ]; + + $total_tax = 0; + foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { + $total_tax += $tax; + + $items[] = [ + 'label' => __( 'Tax', 'woocommerce-payments' ), + 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), + 'pending' => 0 === $tax, + ]; + } + + if ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ) { + $items[] = [ + 'label' => __( 'Shipping', 'woocommerce-payments' ), + 'amount' => 0, + 'pending' => true, + ]; + + $data['shippingOptions'] = [ + 'id' => 'pending', + 'label' => __( 'Pending', 'woocommerce-payments' ), + 'detail' => '', + 'amount' => 0, + ]; + } + + $data['displayItems'] = $items; + $data['total'] = [ + 'label' => apply_filters( 'wcpay_payment_request_total_label', $this->express_checkout_helper->get_total_label() ), + 'amount' => WC_Payments_Utils::prepare_amount( $price + $total_tax, $currency ), + 'pending' => true, + ]; + + $data['needs_shipping'] = ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ); + $data['currency'] = strtolower( $currency ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); + } + + /** + * Gets the product total price. + * + * @param object $product WC_Product_* object. + * @param bool $is_deposit Whether customer is paying a deposit. + * @param int $deposit_plan_id The ID of the deposit plan. + * @return mixed Total price. + * + * @throws Invalid_Price_Exception Whenever a product has no price. + * + * @psalm-suppress UndefinedClass + */ + public function get_product_price( $product, ?bool $is_deposit = null, int $deposit_plan_id = 0 ) { + // If prices should include tax, using tax inclusive price. + if ( $this->express_checkout_helper->cart_prices_include_tax() ) { + $base_price = wc_get_price_including_tax( $product ); + } else { + $base_price = wc_get_price_excluding_tax( $product ); + } + + // If WooCommerce Deposits is active, we need to get the correct price for the product. + if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + if ( is_null( $is_deposit ) ) { + /** + * If is_deposit is null, we use the default deposit type for the product. + * + * @psalm-suppress UndefinedClass + */ + $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); + } + if ( $is_deposit ) { + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + $deposit_type = WC_Deposits_Product_Manager::get_deposit_type( $product->get_id() ); + $available_plan_ids = WC_Deposits_Plans_Manager::get_plan_ids_for_product( $product->get_id() ); + // Default to first (default) plan if no plan is specified. + if ( 'plan' === $deposit_type && 0 === $deposit_plan_id && ! empty( $available_plan_ids ) ) { + $deposit_plan_id = $available_plan_ids[0]; + } + + // Ensure the selected plan is available for the product. + if ( 0 === $deposit_plan_id || in_array( $deposit_plan_id, $available_plan_ids, true ) ) { + $base_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $base_price ); + } + } + } + + // Add subscription sign-up fees to product price. + $sign_up_fee = 0; + $subscription_types = [ + 'subscription', + 'subscription_variation', + ]; + if ( in_array( $product->get_type(), $subscription_types, true ) && class_exists( 'WC_Subscriptions_Product' ) ) { + // When there is no sign-up fee, `get_sign_up_fee` falls back to an int 0. + $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); + } + + if ( ! is_numeric( $base_price ) || ! is_numeric( $sign_up_fee ) ) { + $error_message = sprintf( + // Translators: %d is the numeric ID of the product without a price. + __( 'Express checkout does not support products without prices! Please add a price to product #%d', 'woocommerce-payments' ), + (int) $product->get_id() + ); + throw new Invalid_Price_Exception( + esc_html( $error_message ) + ); + } + + return $base_price + $sign_up_fee; + } + + /** + * 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. + * @return array An array of final taxes. + */ + private function get_taxes_like_cart( $product, $price ) { + if ( ! wc_tax_enabled() || $this->express_checkout_helper->cart_prices_include_tax() ) { + // Only proceed when taxes are enabled, but not included. + return []; + } + + // Follows the way `WC_Cart_Totals::get_item_tax_rates()` works. + $tax_class = $product->get_tax_class(); + $rates = WC_Tax::get_rates( $tax_class ); + // No cart item, `woocommerce_cart_totals_get_item_tax_rates` can't be applied here. + + // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. + return WC_Tax::calc_tax( $price, $rates, false ); + } +} \ No newline at end of file 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/multi-currency/Currency.php b/includes/multi-currency/Currency.php index 23050339271..0d9ae84da42 100644 --- a/includes/multi-currency/Currency.php +++ b/includes/multi-currency/Currency.php @@ -66,24 +66,32 @@ class Currency implements \JsonSerializable { */ private $last_updated; + /** + * Instance of WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Constructor. * - * @param string $code Three letter currency code. - * @param float $rate The conversion rate. - * @param int|null $last_updated The time this currency was last updated. + * @param WC_Payments_Localization_Service $localization_service Localization service instance. + * @param string $code Three letter currency code. + * @param float $rate The conversion rate. + * @param int|null $last_updated The time this currency was last updated. */ - public function __construct( string $code = '', float $rate = 1.0, $last_updated = null ) { - $this->code = $code; - $this->rate = $rate; + public function __construct( WC_Payments_Localization_Service $localization_service, $code = '', float $rate = 1.0, $last_updated = null ) { + $this->localization_service = $localization_service; + $this->code = $code; + $this->rate = $rate; if ( get_woocommerce_currency() === $code ) { $this->is_default = true; } - if ( in_array( strtolower( $code ), WC_Payments_Utils::zero_decimal_currencies(), true ) ) { - $this->is_zero_decimal = true; - } + // Set zero-decimal style based on WC locale information. + $this->is_zero_decimal = 0 === $this->localization_service->get_currency_format( $code )['num_decimals']; if ( ! is_null( $last_updated ) ) { $this->last_updated = $last_updated; @@ -189,8 +197,7 @@ public function get_symbol(): string { * @return string Currency position (left/right). */ public function get_symbol_position(): string { - $localization_service = new WC_Payments_Localization_Service(); - return $localization_service->get_currency_format( $this->code )['currency_pos']; + return $this->localization_service->get_currency_format( $this->code )['currency_pos']; } /** diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index c0698eddf1c..6482d34fb2e 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -244,7 +244,7 @@ public function init_hooks() { $is_frontend_request = ! is_admin() && ! defined( 'DOING_CRON' ) && ! WC()->is_rest_api_request(); - if ( $is_frontend_request ) { + if ( $is_frontend_request || \WC_Payments_Utils::is_store_api_request() ) { // Make sure that this runs after the main init function. add_action( 'init', [ $this, 'update_selected_currency_by_url' ], 11 ); add_action( 'init', [ $this, 'update_selected_currency_by_geolocation' ], 12 ); @@ -325,6 +325,13 @@ public function init() { * @return void */ public function init_rest_api() { + // Ensures we are not initializing our REST during `rest_preload_api_request`. + // When constructors signature changes, in manual update scenarios we were run into fatals. + // Those fatals are not critical, but it causes hickups in release process as catches unnecessary attention. + if ( function_exists( 'get_current_screen' ) && get_current_screen() ) { + return; + } + $api_controller = new RestController( \WC_Payments::create_api_client() ); $api_controller->register_routes(); } @@ -622,7 +629,7 @@ public function maybe_update_customer_currencies_option( $order_id ) { private function initialize_available_currencies() { // Add default store currency with a rate of 1.0. $woocommerce_currency = get_woocommerce_currency(); - $this->available_currencies[ $woocommerce_currency ] = new Currency( $woocommerce_currency, 1.0 ); + $this->available_currencies[ $woocommerce_currency ] = new Currency( $this->localization_service, $woocommerce_currency, 1.0 ); $available_currencies = []; @@ -632,7 +639,7 @@ private function initialize_available_currencies() { foreach ( $currencies as $currency_code ) { $currency_rate = $cache_data['currencies'][ $currency_code ] ?? 1.0; $update_time = $cache_data['updated'] ?? null; - $new_currency = new Currency( $currency_code, $currency_rate, $update_time ); + $new_currency = new Currency( $this->localization_service, $currency_code, $currency_rate, $update_time ); // Add this to our list of available currencies. $available_currencies[ $new_currency->get_name() ] = $new_currency; @@ -725,7 +732,7 @@ public function get_default_currency(): Currency { $this->init(); } - return $this->default_currency ?? new Currency( get_woocommerce_currency() ); + return $this->default_currency ?? new Currency( $this->localization_service, get_woocommerce_currency() ); } /** @@ -805,11 +812,17 @@ public function update_selected_currency( string $currency_code, bool $persist_c $user_id = get_current_user_id(); $currency = $this->get_enabled_currencies()[ $code ] ?? null; + if ( null === $currency ) { + return; + } + // We discard the cache for the front-end. $this->frontend_currencies->selected_currency_changed(); - if ( null === $currency ) { - return; + // initializing the session (useful for Store API), + // so that the selected currency (set as query string parameter) can be correctly set. + if ( ! isset( WC()->session ) ) { + WC()->initialize_session(); } if ( 0 === $user_id && WC()->session ) { @@ -964,7 +977,9 @@ public function get_raw_conversion( float $amount, string $to_currency, string $ * @return void */ public function recalculate_cart() { - WC()->cart->calculate_totals(); + if ( WC()->cart ) { + WC()->cart->calculate_totals(); + } } /** diff --git a/includes/multi-currency/Utils.php b/includes/multi-currency/Utils.php index 12b5326147c..64e5356a77a 100644 --- a/includes/multi-currency/Utils.php +++ b/includes/multi-currency/Utils.php @@ -57,7 +57,7 @@ public function is_page_with_vars( array $pages, array $vars ): bool { * @return boolean */ public static function is_admin_api_request(): bool { - return 0 === stripos( wp_get_referer(), admin_url() ) && WC()->is_rest_api_request(); + return 0 === stripos( wp_get_referer(), admin_url() ) && WC()->is_rest_api_request() && ! \WC_Payments_Utils::is_store_api_request(); } diff --git a/includes/subscriptions/class-wc-payments-invoice-service.php b/includes/subscriptions/class-wc-payments-invoice-service.php index 2e2b23663d2..9fb62999dd3 100644 --- a/includes/subscriptions/class-wc-payments-invoice-service.php +++ b/includes/subscriptions/class-wc-payments-invoice-service.php @@ -92,7 +92,7 @@ public function __construct( * * @return string Invoice ID. */ - public static function get_pending_invoice_id( $subscription ) : string { + public static function get_pending_invoice_id( $subscription ): string { return $subscription->get_meta( self::PENDING_INVOICE_ID_KEY, true ); } @@ -102,7 +102,7 @@ public static function get_pending_invoice_id( $subscription ) : string { * @param WC_Order $order The order. * @return string Invoice ID. */ - public static function get_order_invoice_id( WC_Order $order ) : string { + public static function get_order_invoice_id( WC_Order $order ): string { return $order->get_meta( self::ORDER_INVOICE_ID_KEY, true ); } @@ -334,7 +334,7 @@ public function update_transaction_details( array $invoice, WC_Order $order ) { } $charge = $this->payments_api_client->get_charge( $invoice['charge'] ); - if ( !isset( $charge['balance_transaction'] ) || !isset( $charge['balance_transaction']['id'] ) ) { + if ( ! isset( $charge['balance_transaction'] ) || ! isset( $charge['balance_transaction']['id'] ) ) { return; } @@ -347,14 +347,13 @@ public function update_transaction_details( array $invoice, WC_Order $order ) { 'customer_country' => $order->get_billing_country(), ] ); - } /** * Update a charge with the order id from invoice. * - * @param array $invoice - * @param int $order_id + * @param array $invoice Invoice details. + * @param int $order_id Order ID. * * @return void * @throws API_Exception @@ -366,7 +365,7 @@ public function update_charge_details( array $invoice, int $order_id ) { $this->payments_api_client->update_charge( $invoice['charge'], [ - 'metadata' => ['order_id' => $order_id ], + 'metadata' => [ 'order_id' => $order_id ], ] ); } @@ -392,7 +391,7 @@ private function set_pending_invoice_id( $subscription, string $invoice_id ) { * * @throws Rest_Request_Exception WCPay invoice items do not match WC subscription items. */ - private function get_repair_data_for_wcpay_items( array $wcpay_item_data, WC_Subscription $subscription ) : array { + private function get_repair_data_for_wcpay_items( array $wcpay_item_data, WC_Subscription $subscription ): array { $repair_data = []; $wcpay_items = []; $subscription_items = $subscription->get_items( [ 'line_item', 'fee', 'shipping', 'tax' ] ); diff --git a/includes/subscriptions/class-wc-payments-product-service.php b/includes/subscriptions/class-wc-payments-product-service.php index 68ed6d282a1..35d8159740b 100644 --- a/includes/subscriptions/class-wc-payments-product-service.php +++ b/includes/subscriptions/class-wc-payments-product-service.php @@ -110,7 +110,7 @@ public function __construct( WC_Payments_API_Client $payments_api_client ) { * @param WC_Product $product The product to get the hash for. * @return string The product's hash or an empty string. */ - public static function get_wcpay_product_hash( WC_Product $product ) : string { + public static function get_wcpay_product_hash( WC_Product $product ): string { return $product->get_meta( self::PRODUCT_HASH_KEY, true ); } @@ -122,7 +122,7 @@ public static function get_wcpay_product_hash( WC_Product $product ) : string { * * @return string The WC Pay product ID or an empty string. */ - public function get_wcpay_product_id( WC_Product $product, $test_mode = null ) : string { + public function get_wcpay_product_id( WC_Product $product, $test_mode = null ): string { // If the subscription product doesn't have a WC Pay product ID, create one. if ( ! self::has_wcpay_product_id( $product, $test_mode ) ) { $is_current_environment = null === $test_mode || WC_Payments::mode()->is_test() === $test_mode; @@ -142,7 +142,7 @@ public function get_wcpay_product_id( WC_Product $product, $test_mode = null ) : * @param string $type The item type to create a product for. * @return string The item's WCPay product id. */ - public function get_wcpay_product_id_for_item( string $type ) : string { + public function get_wcpay_product_id_for_item( string $type ): string { $sanitized_type = self::sanitize_option_key( $type ); $option_key_name = self::get_wcpay_product_id_option() . '_' . $sanitized_type; if ( ! get_option( $option_key_name ) ) { @@ -169,7 +169,7 @@ public static function sanitize_option_key( string $type ) { * * @return bool The WC Pay product ID or an empty string. */ - public static function has_wcpay_product_id( WC_Product $product, $test_mode = null ) : bool { + public static function has_wcpay_product_id( WC_Product $product, $test_mode = null ): bool { return (bool) $product->get_meta( self::get_wcpay_product_id_option( $test_mode ) ); } @@ -557,7 +557,7 @@ private function remove_product_update_listeners() { * @param WC_Product $product The product to get data from. * @return array */ - private function get_product_data( WC_Product $product ) : array { + private function get_product_data( WC_Product $product ): array { return [ 'description' => $product->get_description() ? $product->get_description() : 'N/A', 'name' => $product->get_name(), @@ -573,7 +573,7 @@ private function get_product_data( WC_Product $product ) : array { * * @return array The products to update. */ - private function get_products_to_update( WC_Product $product ) : array { + private function get_products_to_update( WC_Product $product ): array { return $product->is_type( 'variable-subscription' ) ? $product->get_available_variations( 'object' ) : [ $product ]; } @@ -584,7 +584,7 @@ private function get_products_to_update( WC_Product $product ) : array { * @param WC_Product $product The product to generate the hash for. * @return string The product's hash. */ - private function get_product_hash( WC_Product $product ) : string { + private function get_product_hash( WC_Product $product ): string { return md5( implode( $this->get_product_data( $product ) ) ); } @@ -595,8 +595,8 @@ private function get_product_hash( WC_Product $product ) : string { * * @return bool Whether the product needs to be update in WC Pay. */ - private function product_needs_update( WC_Product $product ) : bool { - return $this->get_product_hash( $product ) !== static::get_wcpay_product_hash($product); + private function product_needs_update( WC_Product $product ): bool { + return $this->get_product_hash( $product ) !== static::get_wcpay_product_hash( $product ); } /** @@ -628,7 +628,7 @@ private function set_wcpay_product_id( WC_Product $product, string $value ) { * * @return string The WCPay product ID meta key/option name. */ - public static function get_wcpay_product_id_option( $test_mode = null ) : string { + public static function get_wcpay_product_id_option( $test_mode = null ): string { $test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode; return $test_mode ? self::TEST_PRODUCT_ID_KEY : self::LIVE_PRODUCT_ID_KEY; } @@ -640,7 +640,7 @@ public static function get_wcpay_product_id_option( $test_mode = null ) : string * * @return string The price hash option name. */ - public static function get_wcpay_price_id_option( $test_mode = null ) : string { + public static function get_wcpay_price_id_option( $test_mode = null ): string { $test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode; return $test_mode ? self::TEST_PRICE_ID_KEY : self::LIVE_PRICE_ID_KEY; } @@ -762,7 +762,7 @@ public function unarchive_price( string $wcpay_price_id, $test_mode = null ) { * @param WC_Product $product The product to get the hash for. * @return string The product's price hash or an empty string. */ - public static function get_wcpay_price_hash( WC_Product $product ) : string { + public static function get_wcpay_price_hash( WC_Product $product ): string { wc_deprecated_function( __FUNCTION__, '3.3.0' ); return $product->get_meta( self::PRICE_HASH_KEY, true ); } @@ -777,7 +777,7 @@ public static function get_wcpay_price_hash( WC_Product $product ) : string { * * @return string The product's WC Pay price ID or an empty string. */ - public function get_wcpay_price_id( WC_Product $product, $test_mode = null ) : string { + public function get_wcpay_price_id( WC_Product $product, $test_mode = null ): string { wc_deprecated_function( __FUNCTION__, '3.3.0' ); $price_id = $product->get_meta( self::get_wcpay_price_id_option( $test_mode ), true ); diff --git a/includes/subscriptions/class-wc-payments-subscription-migration-log-handler.php b/includes/subscriptions/class-wc-payments-subscription-migration-log-handler.php index 5ae1b4d587e..9c37d727b8c 100644 --- a/includes/subscriptions/class-wc-payments-subscription-migration-log-handler.php +++ b/includes/subscriptions/class-wc-payments-subscription-migration-log-handler.php @@ -78,7 +78,7 @@ public function extend_life_of_migration_file_logs() { foreach ( WC_Log_Handler_File::get_log_files() as $log_file_name ) { // If the log file name starts with our handle, "touch" it to update the last modified timestamp. if ( strpos( $log_file_name, self::HANDLE ) === 0 ) { - touch( trailingslashit( WC_LOG_DIR ) . $log_file_name ); + touch( trailingslashit( WC_LOG_DIR ) . $log_file_name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch } } } diff --git a/includes/subscriptions/class-wc-payments-subscription-service.php b/includes/subscriptions/class-wc-payments-subscription-service.php index bba9ebc6064..229bef20ae7 100644 --- a/includes/subscriptions/class-wc-payments-subscription-service.php +++ b/includes/subscriptions/class-wc-payments-subscription-service.php @@ -269,7 +269,7 @@ public static function set_wcpay_discount_ids( WC_Subscription $subscription, ar * * @return bool */ - public static function is_wcpay_subscription( WC_Subscription $subscription ) : bool { + public static function is_wcpay_subscription( WC_Subscription $subscription ): bool { return ! WC_Payments_Subscriptions::is_duplicate_site() && WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() && (bool) self::get_wcpay_subscription_id( $subscription ); } @@ -284,7 +284,7 @@ public static function is_wcpay_subscription( WC_Subscription $subscription ) : * * @return array Structured invoice item array. */ - public static function format_item_price_data( string $currency, string $wcpay_product_id, float $unit_amount, string $interval = '', int $interval_count = 0 ) : array { + public static function format_item_price_data( string $currency, string $wcpay_product_id, float $unit_amount, string $interval = '', int $interval_count = 0 ): array { $data = [ 'currency' => $currency, 'product' => $wcpay_product_id, @@ -314,7 +314,7 @@ public static function format_item_price_data( string $currency, string $wcpay_p * * @return array WCPay discount item data. */ - public static function get_discount_item_data_for_subscription( WC_Subscription $subscription ) : array { + public static function get_discount_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; foreach ( $subscription->get_items( 'coupon' ) as $item ) { @@ -399,7 +399,7 @@ public function create_subscription( WC_Subscription $subscription ) { $this->set_wcpay_subscription_item_ids( $subscription, $response['items']['data'] ); if ( isset( $response['discounts'] ) ) { - static::set_wcpay_discount_ids($subscription, $response['discounts']); + static::set_wcpay_discount_ids( $subscription, $response['discounts'] ); } if ( ! empty( $response['latest_invoice'] ) ) { @@ -490,7 +490,7 @@ public function handle_subscription_status_on_hold( WC_Subscription $subscriptio // Check if the subscription is a WCPay subscription before proceeding. // In stores that have WC Subscriptions active, or previously had WC S, // this method may be called with regular tokenised subscriptions. - if ( ! static::is_wcpay_subscription($subscription) ) { + if ( ! static::is_wcpay_subscription( $subscription ) ) { return; } @@ -523,7 +523,7 @@ public function handle_subscription_status_on_hold( WC_Subscription $subscriptio */ public function suspend_subscription( WC_Subscription $subscription ) { // Check if the subscription is a WCPay subscription before proceeding. - if ( ! static::is_wcpay_subscription($subscription) ) { + if ( ! static::is_wcpay_subscription( $subscription ) ) { Logger::log( sprintf( 'Aborting WC_Payments_Subscription_Service::suspend_subscription; subscription is a tokenised (non WCPay) subscription. WC ID: %d.', @@ -579,7 +579,7 @@ public function update_wcpay_subscription_payment_method( int $subscription_id, $subscription = wcs_get_subscription( $subscription_id ); if ( $subscription && self::is_wcpay_subscription( $subscription ) ) { - $wcpay_subscription_id = static::get_wcpay_subscription_id($subscription); + $wcpay_subscription_id = static::get_wcpay_subscription_id( $subscription ); $wcpay_payment_method_id = $token->get_token(); if ( $wcpay_subscription_id && $wcpay_payment_method_id ) { @@ -793,14 +793,14 @@ private function prepare_wcpay_subscription_data( string $wcpay_customer_id, WC_ * * @return array WCPay recurring item data. */ - public function get_recurring_item_data_for_subscription( WC_Subscription $subscription ) : array { + public function get_recurring_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; foreach ( $subscription->get_items() as $item ) { $data[] = [ 'metadata' => $this->get_item_metadata( $item ), 'quantity' => $item->get_quantity(), - 'price_data' => static::format_item_price_data($subscription->get_currency(), $this->product_service->get_wcpay_product_id( $item->get_product() ), $item->get_subtotal() / $item->get_quantity(), $subscription->get_billing_period(), $subscription->get_billing_interval()), + 'price_data' => static::format_item_price_data( $subscription->get_currency(), $this->product_service->get_wcpay_product_id( $item->get_product() ), $item->get_subtotal() / $item->get_quantity(), $subscription->get_billing_period(), $subscription->get_billing_interval() ), ]; } @@ -858,7 +858,7 @@ public function maybe_cancel_subscription( $subscription, $new_payment_method ) * * @return array WCPay one time item data. */ - private function get_one_time_item_data_for_subscription( WC_Subscription $subscription ) : array { + private function get_one_time_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; $currency = $subscription->get_currency(); @@ -900,7 +900,7 @@ private function get_one_time_item_data_for_subscription( WC_Subscription $subsc * @return array|null Updated wcpay subscription or null if there was an error. */ private function update_subscription( WC_Subscription $subscription, array $data ) { - $wcpay_subscription_id = static::get_wcpay_subscription_id($subscription); + $wcpay_subscription_id = static::get_wcpay_subscription_id( $subscription ); $response = null; if ( ! $wcpay_subscription_id ) { @@ -1094,6 +1094,6 @@ public static function store_has_active_wcpay_subscriptions() { ] ); - return (is_countable($active_wcpay_subscriptions) ? count( $active_wcpay_subscriptions ) : 0) > 0; + return ( is_countable( $active_wcpay_subscriptions ) ? count( $active_wcpay_subscriptions ) : 0 ) > 0; } } diff --git a/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php b/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php index a3b1ddbf76e..73ad59e23f8 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php +++ b/includes/subscriptions/class-wc-payments-subscriptions-event-handler.php @@ -192,10 +192,10 @@ public function handle_invoice_paid( array $body ) { // Record the store's Stripe Billing environment context on the payment intent. $invoice = $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id ); - // Update charge and transaction metadata - add order id for Stripe Billing + // Update charge and transaction metadata - add order id for Stripe Billing. $this->invoice_service->update_charge_details( $invoice, $order->get_id() ); - // Update transaction customer details for Stripe Billing + // Update transaction customer details for Stripe Billing. $this->invoice_service->update_transaction_details( $invoice, $order ); } diff --git a/includes/subscriptions/class-wc-payments-subscriptions-onboarding-handler.php b/includes/subscriptions/class-wc-payments-subscriptions-onboarding-handler.php index 2a72f5c13e8..3898adaeaea 100644 --- a/includes/subscriptions/class-wc-payments-subscriptions-onboarding-handler.php +++ b/includes/subscriptions/class-wc-payments-subscriptions-onboarding-handler.php @@ -85,7 +85,7 @@ public function product_save( WC_Product $product ) { // Change the default WP saved post URL to correctly reflect the draft status and to add our saved-as-draft flag. add_filter( 'redirect_post_location', - function() use ( $product ) { + function () use ( $product ) { return add_query_arg( // nosemgrep: audit.php.wp.security.xss.query-arg -- server generated url is passed in. [ 'message' => 10, // Post saved as draft message. diff --git a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php index 696867efbd9..7f54f324be6 100644 --- a/includes/subscriptions/templates/html-wcpay-deactivate-warning.php +++ b/includes/subscriptions/templates/html-wcpay-deactivate-warning.php @@ -44,7 +44,7 @@

payment_gateways->get_available_payment_gateways(); if ( ! isset( $gateways['woocommerce_payments'] ) ) { diff --git a/includes/woopay/class-woopay-adapted-extensions.php b/includes/woopay/class-woopay-adapted-extensions.php index bc5a4b37c96..0180525f726 100644 --- a/includes/woopay/class-woopay-adapted-extensions.php +++ b/includes/woopay/class-woopay-adapted-extensions.php @@ -36,7 +36,7 @@ public function init() { public function get_adapted_extensions_data( $email ) { $enabled_adapted_extensions = get_option( WooPay_Scheduler::ENABLED_ADAPTED_EXTENSIONS_OPTION_NAME, [] ); - if ( (is_countable($enabled_adapted_extensions) ? count( $enabled_adapted_extensions ) : 0) === 0 ) { + if ( ( is_countable( $enabled_adapted_extensions ) ? count( $enabled_adapted_extensions ) : 0 ) === 0 ) { return []; } @@ -166,16 +166,18 @@ public function get_extension_data() { $extension_data = []; if ( defined( 'WOOCOMMERCE_MULTICURRENCY_VERSION' ) ) { - $extension_data[ 'woocommerce-multicurrency' ] = [ + $extension_data['woocommerce-multicurrency'] = [ 'currency' => get_woocommerce_currency(), ]; } if ( $this->is_affiliate_for_woocommerce_enabled() ) { /** + * Suppress psalm warning. + * * @psalm-suppress UndefinedFunction */ - $extension_data[ 'affiliate-for-woocommerce' ] = [ + $extension_data['affiliate-for-woocommerce'] = [ 'affiliate-user' => afwc_get_referrer_id(), ]; } @@ -183,7 +185,7 @@ public function get_extension_data() { if ( $this->is_automate_woo_referrals_enabled() ) { $advocate_id = $this->get_automate_woo_advocate_id_from_cookie(); - $extension_data[ 'automatewoo-referrals' ] = [ + $extension_data['automatewoo-referrals'] = [ 'advocate_id' => $advocate_id, ]; } diff --git a/includes/woopay/class-woopay-order-status-sync.php b/includes/woopay/class-woopay-order-status-sync.php index b7d28bbe22e..9691fd4d3f1 100644 --- a/includes/woopay/class-woopay-order-status-sync.php +++ b/includes/woopay/class-woopay-order-status-sync.php @@ -45,7 +45,7 @@ class WooPay_Order_Status_Sync { public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Account $account ) { $this->payments_api_client = $payments_api_client; - $this->account = $account; + $this->account = $account; add_filter( 'woocommerce_webhook_topic_hooks', [ __CLASS__, 'add_topics' ], 20, 2 ); add_filter( 'woocommerce_webhook_payload', [ __CLASS__, 'create_payload' ], 10, 4 ); @@ -143,12 +143,12 @@ public static function add_topics( $topic_hooks ) { /** * Setup payload for the webhook delivery. * - * @param array $payload Data to be sent out by the webhook. - * @param string $resource Type/name of the resource. - * @param integer $resource_id ID of the resource. - * @param integer $id ID of the webhook. + * @param array $payload Data to be sent out by the webhook. + * @param string $resource_name Type/name of the resource. + * @param integer $resource_id ID of the resource. + * @param integer $id ID of the webhook. */ - public static function create_payload( $payload, $resource, $resource_id, $id ) { + public static function create_payload( $payload, $resource_name, $resource_id, $id ) { $webhook = wc_get_webhook( $id ); if ( 0 !== strpos( $webhook->get_delivery_url(), WooPay_Utilities::get_woopay_rest_url( 'merchant-notification' ) ) ) { // This is not a WooPay webhook, so we don't need to modify the payload. @@ -211,6 +211,5 @@ public static function remove_webhook() { $webhook = new \WC_Webhook( $webhook_id ); $webhook->delete(); } - } } diff --git a/includes/woopay/class-woopay-session.php b/includes/woopay/class-woopay-session.php index 7e04124c6cf..11befdcc6cd 100644 --- a/includes/woopay/class-woopay-session.php +++ b/includes/woopay/class-woopay-session.php @@ -33,25 +33,6 @@ class WooPay_Session { const STORE_API_NAMESPACE_PATTERN = '@^wc/store(/v[\d]+)?$@'; - /** - * The Store API route patterns that should be handled by the WooPay session handler. - */ - const STORE_API_ROUTE_PATTERNS = [ - '@^\/wc\/store(\/v[\d]+)?\/cart$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/apply-coupon$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/remove-coupon$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/select-shipping-rate$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/update-customer$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/update-item$@', - '@^\/wc\/store(\/v[\d]+)?\/cart\/extensions$@', - '@^\/wc\/store(\/v[\d]+)?\/checkout\/(?P[\d]+)@', - '@^\/wc\/store(\/v[\d]+)?\/checkout$@', - '@^\/wc\/store(\/v[\d]+)?\/order\/(?P[\d]+)@', - // The route below is not a Store API route. However, this REST endpoint is used by WooPay to indirectly reach the Store API. - // By adding it to this list, we're able to identify the user and load the correct session for this route. - '@^\/wc\/v3\/woopay\/session$@', - ]; - /** * Init the hooks. * @@ -82,7 +63,7 @@ public static function add_woopay_store_api_session_handler( $default_session_ha if ( $cart_token && self::is_request_from_woopay() && - self::is_store_api_request() && + \WC_Payments_Utils::is_store_api_request() && class_exists( JsonWebToken::class ) && JsonWebToken::validate( $cart_token, '@' . wp_salt() ) ) { @@ -100,7 +81,7 @@ class_exists( JsonWebToken::class ) && * @return \WP_User|null|int */ public static function determine_current_user_for_woopay( $user ) { - if ( ! self::is_request_from_woopay() || ! self::is_store_api_request() ) { + if ( ! self::is_request_from_woopay() || ! \WC_Payments_Utils::is_store_api_request() ) { return $user; } @@ -151,7 +132,7 @@ public static function get_user_id_from_cart_token() { // If the email is verified on WooPay, matches session email (set during the redirection), // and the store has an adapted extension installed, // return the user to get extension data without authentication. - if ( (is_countable($enabled_adapted_extensions) ? count( $enabled_adapted_extensions ) : 0) > 0 && null !== $woopay_verified_email_address && ! empty( $customer['email'] ) ) { + if ( ( is_countable( $enabled_adapted_extensions ) ? count( $enabled_adapted_extensions ) : 0 ) > 0 && null !== $woopay_verified_email_address && ! empty( $customer['email'] ) ) { $user = get_user_by( 'email', $woopay_verified_email_address ); if ( $woopay_verified_email_address === $customer['email'] && $user ) { @@ -179,7 +160,7 @@ public static function woopay_order_payment_status_changed( $order_id ) { return; } - if ( ! self::is_request_from_woopay() || ! self::is_store_api_request() ) { + if ( ! self::is_request_from_woopay() || ! \WC_Payments_Utils::is_store_api_request() ) { return; } @@ -194,7 +175,7 @@ public static function woopay_order_payment_status_changed( $order_id ) { $enabled_adapted_extensions = get_option( WooPay_Scheduler::ENABLED_ADAPTED_EXTENSIONS_OPTION_NAME, [] ); - if ( (is_countable($enabled_adapted_extensions) ? count( $enabled_adapted_extensions ) : 0) === 0 ) { + if ( ( is_countable( $enabled_adapted_extensions ) ? count( $enabled_adapted_extensions ) : 0 ) === 0 ) { return; } @@ -266,9 +247,13 @@ public static function run_and_remove_woopay_restore_order_customer_id_schedules /** * Fix for AutomateWoo - Refer A Friend Add-on * plugin when using link referrals. + * + * @param int $advocate_id The advocate ID. + * + * @return false|int|mixed The advocate ID or false if the request is not from WooPay. */ public static function automatewoo_refer_a_friend_referral_from_parameter( $advocate_id ) { - if ( ! self::is_request_from_woopay() || ! self::is_store_api_request() ) { + if ( ! self::is_request_from_woopay() || ! \WC_Payments_Utils::is_store_api_request() ) { return $advocate_id; } @@ -325,7 +310,7 @@ private static function get_payload_from_cart_token() { * @return array The encrypted session request or an empty array if the server is not eligible for encryption. */ public static function get_frontend_init_session_request() { - if ( ! WC_Payments_Features::is_client_secret_encryption_eligible() ) { + if ( ! extension_loaded( 'openssl' ) || ! function_exists( 'openssl_encrypt' ) ) { return []; } @@ -346,21 +331,23 @@ public static function get_frontend_init_session_request() { * If the request doesn't come from WooPay, this uses the same strategy in * `hydrate_from_api` on the Checkout Block to retrieve cart data. * - * @param int|null $order_id Pay-for-order order ID. - * @param string|null $key Pay-for-order key. - * @param string|null $billing_email Pay-for-order billing email. + * @param bool $is_pay_for_order Whether the request is for a pay-for-order session. + * @param int|null $order_id Pay-for-order order ID. + * @param string|null $key Pay-for-order key. + * @param string|null $billing_email Pay-for-order billing email. * @param WP_REST_Request|null $woopay_request The WooPay request object. + * * @return array The cart data. */ private static function get_cart_data( $is_pay_for_order, $order_id, $key, $billing_email, $woopay_request ) { if ( ! $woopay_request ) { return ! $is_pay_for_order ? rest_preload_api_request( [], '/wc/store/v1/cart' )['/wc/store/v1/cart']['body'] - : rest_preload_api_request( [], "/wc/store/v1/order/" . urlencode( $order_id ) . "?key=" . urlencode( $key ) . "&billing_email=" . urlencode( $billing_email ) )[ "/wc/store/v1/order/" . urlencode( $order_id ) . "?key=" . urlencode( $key ) . "&billing_email=" . urlencode( $billing_email ) ]['body']; + : rest_preload_api_request( [], '/wc/store/v1/order/' . rawurlencode( $order_id ) . '?key=' . rawurlencode( $key ) . '&billing_email=' . rawurlencode( $billing_email ) )[ '/wc/store/v1/order/' . rawurlencode( $order_id ) . '?key=' . rawurlencode( $key ) . '&billing_email=' . rawurlencode( $billing_email ) ]['body']; } $cart_request = new WP_REST_Request( 'GET', '/wc/store/v1/cart' ); - $cart_request->set_header( 'Cart-Token', $woopay_request->get_header('cart_token') ); + $cart_request->set_header( 'Cart-Token', $woopay_request->get_header( 'cart_token' ) ); return rest_do_request( $cart_request )->get_data(); } @@ -378,10 +365,10 @@ private static function get_checkout_data( $woopay_request ) { if ( ! $woopay_request ) { $preloaded_checkout_data = rest_preload_api_request( [], '/wc/store/v1/checkout' ); - $checkout_data = isset( $preloaded_checkout_data['/wc/store/v1/checkout'] ) ? $preloaded_checkout_data['/wc/store/v1/checkout']['body'] : ''; + $checkout_data = isset( $preloaded_checkout_data['/wc/store/v1/checkout'] ) ? $preloaded_checkout_data['/wc/store/v1/checkout']['body'] : ''; } else { $checkout_request = new WP_REST_Request( 'GET', '/wc/store/v1/checkout' ); - $checkout_request->set_header( 'Cart-Token', $woopay_request->get_header('cart_token') ); + $checkout_request->set_header( 'Cart-Token', $woopay_request->get_header( 'cart_token' ) ); $checkout_data = rest_do_request( $checkout_request )->get_data(); } @@ -393,9 +380,9 @@ private static function get_checkout_data( $woopay_request ) { /** * Returns the initial session request data. * - * @param int|null $order_id Pay-for-order order ID. - * @param string|null $key Pay-for-order key. - * @param string|null $billing_email Pay-for-order billing email. + * @param int|null $order_id Pay-for-order order ID. + * @param string|null $key Pay-for-order key. + * @param string|null $billing_email Pay-for-order billing email. * @param WP_REST_Request|null $woopay_request The WooPay request object. * @return array The initial session request data without email and user_session. */ @@ -435,14 +422,14 @@ public static function get_init_session_request( $order_id = null, $key = null, include_once WCPAY_ABSPATH . 'includes/compat/blocks/class-blocks-data-extractor.php'; $blocks_data_extractor = new Blocks_Data_Extractor(); - $cart_data = self::get_cart_data( $is_pay_for_order, $order_id, $key, $billing_email, $woopay_request ); + $cart_data = self::get_cart_data( $is_pay_for_order, $order_id, $key, $billing_email, $woopay_request ); $checkout_data = self::get_checkout_data( $woopay_request ); if ( $woopay_request ) { $order_id = $checkout_data['order_id'] ?? null; } - $email = ! empty( $_POST['email'] ) ? wc_clean( wp_unslash( $_POST['email'] ) ) : ''; + $email = ! empty( $_POST['email'] ) ? wc_clean( wp_unslash( $_POST['email'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification $request = [ 'wcpay_version' => WCPAY_VERSION_NUMBER, @@ -483,8 +470,8 @@ public static function get_init_session_request( $order_id = null, $key = null, 'tracks_user_identity' => WC_Payments::woopay_tracker()->tracks_get_identity(), ]; - $woopay_adapted_extensions = new WooPay_Adapted_Extensions(); - $request['extension_data'] = $woopay_adapted_extensions->get_extension_data(); + $woopay_adapted_extensions = new WooPay_Adapted_Extensions(); + $request['extension_data'] = $woopay_adapted_extensions->get_extension_data(); if ( ! empty( $email ) ) { // Save email in session to skip TYP verify email and check if @@ -573,7 +560,7 @@ public static function ajax_get_woopay_session() { ); } - $blog_id = Jetpack_Options::get_option('id'); + $blog_id = Jetpack_Options::get_option( 'id' ); if ( empty( $blog_id ) ) { wp_send_json_error( __( 'Could not determine the blog ID.', 'woocommerce-payments' ), @@ -599,7 +586,7 @@ public static function ajax_get_woopay_minimum_session_data() { ); } - $blog_id = Jetpack_Options::get_option('id'); + $blog_id = Jetpack_Options::get_option( 'id' ); if ( empty( $blog_id ) ) { wp_send_json_error( __( 'Could not determine the blog ID.', 'woocommerce-payments' ), @@ -616,7 +603,7 @@ public static function ajax_get_woopay_minimum_session_data() { * @return array Array of minimum session data used by WooPay or false on failures. */ public static function get_woopay_minimum_session_data() { - if ( ! WC_Payments_Features::is_client_secret_encryption_eligible() ) { + if ( ! extension_loaded( 'openssl' ) || ! function_exists( 'openssl_encrypt' ) ) { return []; } @@ -648,30 +635,6 @@ private static function get_woopay_verified_email_address() { return $has_woopay_verified_email_address ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WOOPAY_VERIFIED_EMAIL_ADDRESS'] ) ) : null; } - /** - * Returns true if the request that's currently being processed is a Store API request, false - * otherwise. - * - * @return bool True if request is a Store API request, false otherwise. - */ - private static function is_store_api_request(): bool { - if ( isset( $_REQUEST['rest_route'] ) ) { - $rest_route = sanitize_text_field( $_REQUEST['rest_route'] ); - } else { - $url_parts = wp_parse_url( esc_url_raw( $_SERVER['REQUEST_URI'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $request_path = rtrim( $url_parts['path'], '/' ); - $rest_route = str_replace( trailingslashit( rest_get_url_prefix() ), '', $request_path ); - } - - foreach ( self::STORE_API_ROUTE_PATTERNS as $pattern ) { - if ( 1 === preg_match( $pattern, $rest_route ) ) { - return true; - } - } - - return false; - } - /** * Returns true if the request that's currently being processed is from WooPay, false * otherwise. @@ -758,7 +721,7 @@ private static function create_woopay_nonce( int $uid ) { private static function get_formatted_custom_message() { $custom_message = WC_Payments::get_gateway()->get_option( 'platform_checkout_custom_message' ); - $terms_value = wc_terms_and_conditions_page_id() ? + $terms_value = wc_terms_and_conditions_page_id() ? '
' . __( 'Terms of Service', 'woocommerce-payments' ) . '' : __( 'Terms of Service', 'woocommerce-payments' ); $privacy_policy_value = wc_privacy_policy_page_id() ? 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/package-lock.json b/package-lock.json index 82be17a7a26..7510fd8e2ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "7.6.0", + "version": "7.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "7.6.0", + "version": "7.7.0", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { @@ -17,7 +17,6 @@ "@woocommerce/explat": "2.3.0", "@woocommerce/number": "2.4.0", "canvas-confetti": "1.9.2", - "crypto-js": "4.1.1", "debug": "4.1.1", "intl-tel-input": "17.0.15", "lodash": "4.17.21" @@ -20365,11 +20364,6 @@ "node": "*" } }, - "node_modules/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" - }, "node_modules/css": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", @@ -58528,11 +58522,6 @@ "randomfill": "^1.0.3" } }, - "crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" - }, "css": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", diff --git a/package.json b/package.json index dcc53a83834..11ff44e46c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "7.6.0", + "version": "7.7.0", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -45,7 +45,9 @@ "test:php-coverage": "./bin/check-test-coverage.sh", "test:php-coverage-src": "./bin/check-test-coverage.sh src", "test:php-watch": "npm run test:php -- -w", - "test:qit": "npm run build:release && ./tests/qit/security.sh", + "test:qit-security": "npm run build:release && ./tests/qit/security.sh", + "test:qit-phpstan": "npm run build:release && ./tests/qit/phpstan.sh", + "test:qit-phpstan-local": "npm run build:release && ./tests/qit/phpstan.sh --local", "watch": "webpack --watch", "hmr": "webpack server", "start": "npm run watch", @@ -80,7 +82,6 @@ "@woocommerce/explat": "2.3.0", "@woocommerce/number": "2.4.0", "canvas-confetti": "1.9.2", - "crypto-js": "4.1.1", "debug": "4.1.1", "intl-tel-input": "17.0.15", "lodash": "4.17.21" diff --git a/phpcs.xml.dist b/phpcs.xml.dist index da961eb9a04..6e21606d2bb 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -69,6 +69,7 @@ templates/emails/ includes/in-person-payments/templates/ + includes/compat/subscriptions/emails @@ -111,6 +112,7 @@ + @@ -137,7 +139,7 @@ - + diff --git a/readme.txt b/readme.txt index 5ab94edab84..1aa88ad593b 100644 --- a/readme.txt +++ b/readme.txt @@ -2,9 +2,9 @@ Contributors: woocommerce, automattic Tags: woocommerce payments, apple pay, credit card, google pay, payment, payment gateway Requires at least: 6.0 -Tested up to: 6.4 +Tested up to: 6.5 Requires PHP: 7.3 -Stable tag: 7.6.0 +Stable tag: 7.7.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -94,6 +94,36 @@ Please note that our support for the checkout block is still experimental and th == Changelog == += 7.7.0 - 2024-05-29 = +* Add - Add share key query param when sending data to Stripe KYC. +* Add - Add the WooPay Direct Checkout flow to the blocks mini cart widget. +* Add - feat: add multi-currency support to Store API. +* Add - feat: error message on 1M+ amount. +* Add - feat: tokenized cart PRBs on PDPs via feature flag. +* Add - Render ECE buttons behind a feature flag. +* Fix - Charm pricing and rounding options corrected for all currencies that aren't presented with decimal points. +* Fix - Fix "Pay for order" infinite loading when submitting form without payment details. +* Fix - fix: remove WooPay checkout pages scripts from non-checkout pages. +* Fix - fix: settings notices consistency. +* Fix - fix: Store API tokenized cart nonce verification. +* Fix - Fix a bug in Tracks where shopper events are not fired properly. +* Fix - Fix ECE error in the blocks checkout when PRBs are disabled. +* Fix - Fix Payment block render error while editing the block checkout page. +* Fix - Fix shortcode orders when using WooPay Direct Checkout. +* Fix - Improve visibility of WooPay button on light and outline button themes. +* Fix - Updating saved payment method billing address before processing the payment. +* Update - Do not auto-redirect to WooPay on page load. +* Update - Pass previous exception with exception. +* Update - Removed deprecated deposit_status key from account status. +* Update - Remove public key encryption setting from WooPayments settings. +* Update - Update XPF currency formatting. +* Dev - Add command to run QIT PHPStan tests. +* Dev - Add local release package support for PHPStan. +* Dev - Bump tested up to version for WP to 6.5 and WC to 8.9.1. +* Dev - Fix Klarna E2E tests. +* Dev - Guarantee REST intialization on REST request context (avoiding rest_preload_api_request context). +* Dev - Upgrade jetpack sync package version. + = 7.6.0 - 2024-05-08 = * Add - Add additional data to Compatibility service * Add - Add User Satisfaction Survey for Payments Overview Widget diff --git a/src/Internal/Service/PaymentProcessingService.php b/src/Internal/Service/PaymentProcessingService.php index c68be314128..9e1ce4f4f3a 100644 --- a/src/Internal/Service/PaymentProcessingService.php +++ b/src/Internal/Service/PaymentProcessingService.php @@ -117,17 +117,6 @@ public function get_authentication_redirect_url( $intent, int $order_id ) { $client_secret = $intent->get_client_secret(); - if ( $this->legacy_proxy->call_static( \WC_Payments_Features::class, 'is_client_secret_encryption_enabled' ) ) { - $client_secret = $this->legacy_proxy->call_function( - 'openssl_encrypt', - $client_secret, - 'aes-128-cbc', - substr( $intent->get_customer_id(), 5 ), - 0, - str_repeat( 'WC', 8 ) - ); - } - return sprintf( '#wcpay-confirm-%s:%s:%s:%s', $intent instanceof WC_Payments_API_Setup_Intention ? 'si' : 'pi', diff --git a/tests/e2e/specs/wcpay/shopper/shopper-klarna-checkout.spec.js b/tests/e2e/specs/wcpay/shopper/shopper-klarna-checkout.spec.js index 212266d53bc..903d5d53593 100644 --- a/tests/e2e/specs/wcpay/shopper/shopper-klarna-checkout.spec.js +++ b/tests/e2e/specs/wcpay/shopper/shopper-klarna-checkout.spec.js @@ -14,8 +14,6 @@ const UPE_METHOD_CHECKBOXES = [ "//label[contains(text(), 'Klarna')]/preceding-sibling::span/input[@type='checkbox']", ]; -const checkoutPaymentMethodSelector = `//*[@id='payment']/ul/li/label[contains(text(), 'Klarna')]`; - describe( 'Klarna checkout', () => { beforeAll( async () => { await merchant.login(); @@ -51,7 +49,7 @@ describe( 'Klarna checkout', () => { if ( element ) { element.click(); } - }, 'button[aria-label="Open Learn More Modal"]' ); + }, 'a[aria-label="Open Learn More Modal"]' ); // Wait for the iframe to be added by Stripe JS after clicking on the element. await page.waitFor( 1000 ); @@ -85,17 +83,23 @@ describe( 'Klarna checkout', () => { // https://docs.klarna.com/resources/test-environment/sample-customer-data/#united-states-of-america email: 'customer@email.us', phone: '+13106683312', + firstname: 'Test', + lastname: 'Person-us', }, [ [ 'Beanie', 3 ] ] ); await uiUnblocked(); - await page.waitForXPath( checkoutPaymentMethodSelector ); - const [ paymentMethodLabel ] = await page.$x( - checkoutPaymentMethodSelector - ); - await paymentMethodLabel.click(); + await page.evaluate( async () => { + const paymentMethodLabel = document.querySelector( + 'label[for="payment_method_woocommerce_payments_klarna"]' + ); + if ( paymentMethodLabel ) { + paymentMethodLabel.click(); + } + } ); + await shopper.placeOrder(); // Klarna is rendered in an iframe, so we need to get its reference. @@ -112,66 +116,64 @@ describe( 'Klarna checkout', () => { let klarnaIframe = await getNewKlarnaIframe(); const frameNavigationHandler = async ( frame ) => { - const newKlarnaIframe = await getNewKlarnaIframe(); - if ( frame === newKlarnaIframe ) { - klarnaIframe = newKlarnaIframe; + if ( frame.url().includes( 'klarna.com' ) ) { + const newKlarnaIframe = await getNewKlarnaIframe(); + + if ( frame === newKlarnaIframe ) { + klarnaIframe = newKlarnaIframe; + } } }; // Add frame navigation event listener. page.on( 'framenavigated', frameNavigationHandler ); - // waiting for the redirect & the Klarna iframe to load within the Stripe test page. + // Waiting for the redirect & the Klarna iframe to load within the Stripe test page. // this is the "confirm phone number" page - we just click "continue". - await klarnaIframe.waitForSelector( '#collectPhonePurchaseFlow' ); - ( - await klarnaIframe.waitForSelector( - '#onContinue[data-testid="kaf-button"]' - ) - ).click(); - // this is where the OTP code is entered. - await klarnaIframe.waitForSelector( '#phoneOtp' ); - await expect( klarnaIframe ).toFill( - '[data-testid="kaf-field"]', - '000000' - ); - - await klarnaIframe.waitForSelector( - 'button[data-testid="select-payment-category"' - ); + await klarnaIframe.waitForSelector( '#phone' ); + await klarnaIframe + .waitForSelector( '#onContinue' ) + .then( ( button ) => button.click() ); - await klarnaIframe.waitForSelector( '.skeleton-wrapper' ); - await klarnaIframe.waitFor( - () => ! document.querySelector( '.skeleton-wrapper' ) - ); + // This is where the OTP code is entered. + await klarnaIframe.waitForSelector( '#phoneOtp' ); + await expect( klarnaIframe ).toFill( 'input#otp_field', '123456' ); // Select Payment Plan - 4 weeks & click continue. await klarnaIframe - .waitForSelector( 'input[type="radio"][id*="pay_in_n"]' ) - .then( ( input ) => input.click() ); + .waitForSelector( 'button#pay_over_time__label' ) + .then( ( button ) => button.click() ); + + await page.waitFor( 2000 ); + await klarnaIframe .waitForSelector( 'button[data-testid="select-payment-category"' ) .then( ( button ) => button.click() ); + await page.waitFor( 2000 ); + // Payment summary page. Click continue. await klarnaIframe .waitForSelector( 'button[data-testid="pick-plan"]' ) .then( ( button ) => button.click() ); - // at this point, the event listener is not needed anymore. + await page.waitFor( 2000 ); + + // At this point, the event listener is not needed anymore. page.removeListener( 'framenavigated', frameNavigationHandler ); + await page.waitFor( 2000 ); + // Confirm payment. await klarnaIframe - .waitForSelector( - 'button[data-testid="confirm-and-pay"]:not(:disabled)' - ) + .waitForSelector( 'button#buy_button' ) .then( ( button ) => button.click() ); // Wait for the order confirmation page to load. await page.waitForNavigation( { waitUntil: 'networkidle0', } ); + await expect( page ).toMatch( 'Order received' ); } ); } ); diff --git a/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js b/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js index 3d3045c12a8..52abf98140c 100644 --- a/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js +++ b/tests/e2e/specs/wcpay/shopper/shopper-multi-currency-widget.spec.js @@ -58,8 +58,8 @@ describe( 'Shopper Multi-Currency widget', () => { if ( ! wasMulticurrencyEnabled ) { await merchant.login(); await merchantWCP.deactivateMulticurrency(); - await merchant.logout(); } + await merchant.logout(); } ); it( 'should display currency switcher widget if multi-currency is enabled', async () => { diff --git a/tests/qit/common.sh b/tests/qit/common.sh new file mode 100644 index 00000000000..c95ef0ed25a --- /dev/null +++ b/tests/qit/common.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -e + +cwd=$(pwd) +WCP_ROOT=$cwd +QIT_ROOT="$cwd/tests/qit" +EXTENSION_NAME="woocommerce-payments" + +#Load local env variables if present. +if [[ -f "$QIT_ROOT/config/local.env" ]]; then + . "$QIT_ROOT/config/local.env" +fi + +# Check if QIT_USER and QIT_APP_PASSWORD are set and not empty +if [[ -z $QIT_USER ]] || [[ -z $QIT_PASSWORD ]]; then + echo "QIT_USER or QIT_APP_PASSWORD environment variables are not set or empty. Please set them in the local env file before running the script." + exit 1 +fi + +export QIT_DISABLE_ONBOARDING=yes + +# If QIT_BINARY is not set, default to ./vendor/bin/qit +QIT_BINARY=${QIT_BINARY:-./vendor/bin/qit} + +# Add the partner by validating credentials. +if ! $QIT_BINARY list | grep -q 'partner:remove'; then + echo "Adding partner with QIT credentials..." + $QIT_BINARY partner:add --user=$QIT_USER --application_password=$QIT_PASSWORD + if [ $? -ne 0 ]; then + echo "Failed to add partner. Exiting with status 1." + exit 1 + fi +fi diff --git a/tests/qit/phpstan.sh b/tests/qit/phpstan.sh new file mode 100755 index 00000000000..b1bc5a7d612 --- /dev/null +++ b/tests/qit/phpstan.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +# Get the directory of the current script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +# Source common.sh using the relative path +source "$DIR/common.sh" + +# Check if the --local flag is provided which means the tests should run against the development build +ZIP_FILE="" +if echo "$@" | grep -q -- "--local"; then + ZIP_FILE="$WCP_ROOT/woocommerce-payments.zip" + + # Check if the zip file exists + if [[ ! -f "$ZIP_FILE" ]]; then + echo "Zip file $ZIP_FILE does not exist. Please ensure the zip file is present in the main folder." + exit 1 + fi + + echo "Running PHPStan tests with development build $ZIP_FILE..." + $QIT_BINARY run:phpstan "$EXTENSION_NAME" --zip "$ZIP_FILE" --wait +else + echo "Running PHPStan tests..." + $QIT_BINARY run:phpstan "$EXTENSION_NAME" --wait +fi + +if [ $? -ne 0 ]; then + echo "Failed to run PHPStan command. Exiting with status 1." + exit 1 +fi \ No newline at end of file diff --git a/tests/qit/security.sh b/tests/qit/security.sh index 4c9edcdfe19..a38d852fe8c 100755 --- a/tests/qit/security.sh +++ b/tests/qit/security.sh @@ -1,42 +1,14 @@ #!/usr/bin/env bash -set -e +# Get the directory of the current script +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -cwd=$(pwd) -WCP_ROOT=$cwd -QIT_ROOT="$cwd/tests/qit" -EXTENSION_NAME="woocommerce-payments" +# Source common.sh using the relative path +source "$DIR/common.sh" -#Load local env variables if present. -if [[ -f "$QIT_ROOT/config/local.env" ]]; then - . "$QIT_ROOT/config/local.env" -fi - -# Check if QIT_USER and QIT_APP_PASSWORD are set and not empty -if [[ -z $QIT_USER ]] || [[ -z $QIT_PASSWORD ]]; then - echo "QIT_USER or QIT_APP_PASSWORD environment variables are not set or empty. Please set them in the local env file before running the script." - exit 1 -fi - -export QIT_DISABLE_ONBOARDING=yes - -# If QIT_BINARY is not set, default to ./vendor/bin/qit -QIT_BINARY=${QIT_BINARY:-./vendor/bin/qit} - -# Add the partner by validating credentials. -if ! $QIT_BINARY list | grep -q 'partner:remove'; then - echo "Adding partner with QIT credentials..." - $QIT_BINARY partner:add --user=$QIT_USER --application_password=$QIT_PASSWORD - if [ $? -ne 0 ]; then - echo "Failed to add partner. Exiting with status 1." - exit 1 - fi -fi - -# Run the security command echo "Running security tests..." $QIT_BINARY run:security woocommerce-payments --zip=woocommerce-payments.zip --wait if [ $? -ne 0 ]; then - echo "Failed to run security command. Exiting with status 1." - exit 1 + echo "Failed to run security command. Exiting with status 1." + exit 1 fi diff --git a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php index 6bfaff2b816..5bf31255461 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-settings-controller.php @@ -123,9 +123,9 @@ public function set_up() { $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); $this->mock_db_cache = $this->createMock( Database_Cache::class ); $this->mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); - $customer_service = new WC_Payments_Customer_Service( $this->mock_api_client, $this->mock_wcpay_account, $this->mock_db_cache, $this->mock_session_service ); - $token_service = new WC_Payments_Token_Service( $this->mock_api_client, $customer_service ); $order_service = new WC_Payments_Order_Service( $this->mock_api_client ); + $customer_service = new WC_Payments_Customer_Service( $this->mock_api_client, $this->mock_wcpay_account, $this->mock_db_cache, $this->mock_session_service, $order_service ); + $token_service = new WC_Payments_Token_Service( $this->mock_api_client, $customer_service ); $action_scheduler_service = new WC_Payments_Action_Scheduler_Service( $this->mock_api_client, $order_service ); $mock_rate_limiter = $this->createMock( Session_Rate_Limiter::class ); $mock_dpps = $this->createMock( Duplicate_Payment_Prevention_Service::class ); diff --git a/tests/unit/admin/test-class-wc-rest-payments-tos-controller.php b/tests/unit/admin/test-class-wc-rest-payments-tos-controller.php index f2f5b4274f7..804ca19117f 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-tos-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-tos-controller.php @@ -60,9 +60,9 @@ public function set_up() { $mock_fraud_service = $this->createMock( WC_Payments_Fraud_Service::class ); $mock_db_cache = $this->createMock( Database_Cache::class ); $mock_session_service = $this->createMock( WC_Payments_Session_Service::class ); - $customer_service = new WC_Payments_Customer_Service( $mock_api_client, $mock_wcpay_account, $mock_db_cache, $mock_session_service ); - $token_service = new WC_Payments_Token_Service( $mock_api_client, $customer_service ); $order_service = new WC_Payments_Order_Service( $this->createMock( WC_Payments_API_Client::class ) ); + $customer_service = new WC_Payments_Customer_Service( $mock_api_client, $mock_wcpay_account, $mock_db_cache, $mock_session_service, $order_service ); + $token_service = new WC_Payments_Token_Service( $mock_api_client, $customer_service ); $action_scheduler_service = new WC_Payments_Action_Scheduler_Service( $mock_api_client, $order_service ); $mock_dpps = $this->createMock( Duplicate_Payment_Prevention_Service::class ); $mock_payment_method = $this->createMock( CC_Payment_Method::class ); diff --git a/tests/unit/core/server/request/test-class-core-get-reporting-payment-activity-request.php b/tests/unit/core/server/request/test-class-core-get-reporting-payment-activity-request.php new file mode 100644 index 00000000000..73f22dff33a --- /dev/null +++ b/tests/unit/core/server/request/test-class-core-get-reporting-payment-activity-request.php @@ -0,0 +1,93 @@ +mock_api_client = $this->createMock( WC_Payments_API_Client::class ); + $this->mock_wc_payments_http_client = $this->createMock( WC_Payments_Http_Interface::class ); + } + + public function test_exception_will_throw_if_required_params_are_not_set() { + $this->expectException( Invalid_Request_Parameter_Exception::class ); + $this->expectExceptionMessage( 'date_start' ); + $this->expectExceptionMessage( 'date_end' ); + $this->expectExceptionMessage( 'timezone' ); + + $request = new Get_Reporting_Payment_Activity( $this->mock_api_client, $this->mock_wc_payments_http_client, null ); + $request->get_params(); + } + + public function test_exception_will_throw_if_date_start_is_invalid() { + $this->expectException( Invalid_Request_Parameter_Exception::class ); + $this->expectExceptionMessage( 'abc is not a valid date' ); + + $request = new Get_Reporting_Payment_Activity( $this->mock_api_client, $this->mock_wc_payments_http_client, null ); + $request->set_date_start( 'abc' ); + $request->set_date_end( '2024-05-06T23:59:59' ); + $request->set_timezone( 'America/Los_Angeles' ); + } + + public function test_exception_will_throw_if_date_end_is_invalid() { + $this->expectException( Invalid_Request_Parameter_Exception::class ); + $this->expectExceptionMessage( 'abc is not a valid date' ); + + $request = new Get_Reporting_Payment_Activity( $this->mock_api_client, $this->mock_wc_payments_http_client, null ); + $request->set_date_start( '2024-05-06T23:59:59' ); + $request->set_date_end( 'abc' ); + $request->set_timezone( 'America/Los_Angeles' ); + } + + public function test_exception_will_throw_if_timezone_is_invalid() { + $this->expectException( Invalid_Request_Parameter_Exception::class ); + $this->expectExceptionMessage( 'abc is not a valid timezone' ); + + $request = new Get_Reporting_Payment_Activity( $this->mock_api_client, $this->mock_wc_payments_http_client, null ); + $request->set_date_start( '2024-05-06T00:00:00' ); + $request->set_date_end( '2024-05-06T23:59:59' ); + $request->set_timezone( 'abc' ); + } + + public function test_if_parameters_are_valid() { + $request = new Get_Reporting_Payment_Activity( $this->mock_api_client, $this->mock_wc_payments_http_client, null ); + $request->set_date_start( '2024-05-06T00:00:00' ); + $request->set_date_end( '2024-05-06T23:59:59' ); + $request->set_timezone( 'America/Los_Angeles' ); + + $this->assertSame( '2024-05-06T00:00:00', $request->get_param( 'date_start' ) ); + $this->assertSame( '2024-05-06T23:59:59', $request->get_param( 'date_end' ) ); + $this->assertSame( 'America/Los_Angeles', $request->get_param( 'timezone' ) ); + } +} diff --git a/tests/unit/core/service/test-class-wc-payments-customer-service-api.php b/tests/unit/core/service/test-class-wc-payments-customer-service-api.php index c20b060c0ef..e7b7ae7db8e 100644 --- a/tests/unit/core/service/test-class-wc-payments-customer-service-api.php +++ b/tests/unit/core/service/test-class-wc-payments-customer-service-api.php @@ -62,7 +62,7 @@ public function set_up() { 'wc_payments_http', [ $this, 'replace_http_client' ] ); - $this->customer_service = new WC_Payments_Customer_Service( WC_Payments::create_api_client(), WC_Payments::get_account_service(), WC_Payments::get_database_cache(), WC_Payments::get_session_service() ); + $this->customer_service = new WC_Payments_Customer_Service( WC_Payments::create_api_client(), WC_Payments::get_account_service(), WC_Payments::get_database_cache(), WC_Payments::get_session_service(), WC_Payments::get_order_service() ); $this->customer_service_api = new WC_Payments_Customer_Service_API( $this->customer_service ); } @@ -339,15 +339,16 @@ function ( $data ): bool { 'test_mode' => false, 'billing_details' => [ 'address' => [ - 'city' => $order->get_billing_city(), 'country' => $order->get_billing_country(), 'line1' => $order->get_billing_address_1(), - 'postal_code' => $order->get_billing_postcode(), + 'line2' => $order->get_billing_address_2(), + 'city' => $order->get_billing_city(), 'state' => $order->get_billing_state(), + 'postal_code' => $order->get_billing_postcode(), ], + 'phone' => $order->get_billing_phone(), 'email' => $order->get_billing_email(), 'name' => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(), - 'phone' => $order->get_billing_phone(), ], ] ), diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php index 35f74fe71f3..82409afeb38 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-bookings.php @@ -44,6 +44,13 @@ class WCPay_Multi_Currency_WooCommerceBookings_Tests extends WCPAY_UnitTestCase */ private $woocommerce_bookings; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ @@ -54,6 +61,7 @@ public function set_up() { $this->mock_utils = $this->createMock( Utils::class ); $this->mock_frontend_currencies = $this->createMock( FrontendCurrencies::class ); $this->woocommerce_bookings = new WooCommerceBookings( $this->mock_multi_currency, $this->mock_utils, $this->mock_frontend_currencies ); + $this->localization_service = new WC_Payments_Localization_Service(); } public function test_get_price_returns_empty_string() { @@ -152,7 +160,7 @@ public function test_filter_wc_price_args_returns_expected_results() { 'price_format' => '%1$s%2$s', ]; - $this->mock_multi_currency->method( 'get_selected_currency' )->willReturn( new Currency( $expected['currency'] ) ); + $this->mock_multi_currency->method( 'get_selected_currency' )->willReturn( new Currency( $this->localization_service, $expected['currency'] ) ); $this->mock_frontend_currencies->method( 'get_price_decimal_separator' )->willReturn( $expected['decimal_separator'] ); $this->mock_frontend_currencies->method( 'get_price_thousand_separator' )->willReturn( $expected['thousand_separator'] ); $this->mock_frontend_currencies->method( 'get_price_decimals' )->willReturn( $expected['decimals'] ); diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-name-your-price.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-name-your-price.php index bb563f80b60..d44c1f94b76 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-name-your-price.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-name-your-price.php @@ -38,6 +38,13 @@ class WCPay_Multi_Currency_WooCommerceNameYourPrice_Tests extends WCPAY_UnitTest */ private $woocommerce_nyp; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ @@ -45,9 +52,10 @@ public function setUp(): void { parent::setUp(); // Create the class instances needed for testing. - $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); - $this->mock_utils = $this->createMock( Utils::class ); - $this->woocommerce_nyp = new WooCommerceNameYourPrice( $this->mock_multi_currency, $this->mock_utils ); + $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); + $this->mock_utils = $this->createMock( Utils::class ); + $this->woocommerce_nyp = new WooCommerceNameYourPrice( $this->mock_multi_currency, $this->mock_utils ); + $this->localization_service = new WC_Payments_Localization_Service(); // Set is_nyp to return false by default. $this->set_is_nyp( false ); @@ -95,7 +103,7 @@ public function test_add_initial_currency_returns_unmodified_cart_item_if_nyp_no // Check to make sure the proper elements are added to the cart_item array. public function test_add_initial_currency_returns_modified_cart_item() { // Arrange: Set up the currency used for the test. - $currency = new Currency( 'EUR', 2.0 ); + $currency = new Currency( $this->localization_service, 'EUR', 2.0 ); // Arrange: Set up the cart_item and expected cart_item, set is_nyp to return true. $nyp_value = 12.34; @@ -144,7 +152,7 @@ public function test_convert_cart_currency_returns_unmodified_cart_item() { // If the selected currency matches the currency of the item, then it should just return the item. public function test_convert_cart_currency_returns_cart_item_with_original_value() { // Arrange: Set up the currency used for the test. - $currency = new Currency( 'EUR', 2.0 ); + $currency = new Currency( $this->localization_service, 'EUR', 2.0 ); // Arrange: Set up the cart_item. $nyp_value = 12.34; @@ -174,8 +182,8 @@ public function test_convert_cart_currency_returns_cart_item_with_original_value // Convert the amount of the item to the selected currency. public function test_convert_cart_currency_returns_cart_item_with_converted_value() { // Arrange: Set up the currencies used for the test. - $item_currency = new Currency( 'EUR', 2.0 ); - $selected_currency = new Currency( 'GBP', 0.5 ); + $item_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'GBP', 0.5 ); // Arrange: Set up the cart_item. $nyp_value = 12.34; @@ -214,8 +222,8 @@ public function test_convert_cart_currency_returns_cart_item_with_converted_valu // Convert the amount of the item into the default (selected) currency. public function test_convert_cart_currency_returns_cart_item_with_converted_value_with_default_currency() { // Arrange: Set up the currencies used for the test. - $item_currency = new Currency( 'EUR', 2.0 ); - $selected_currency = new Currency( 'USD', 1 ); + $item_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'USD', 1 ); // Arrange: Set up the cart_item. $nyp_value = 12.34; @@ -260,7 +268,7 @@ public function test_should_convert_product_price_returns_false_when_passed_fals // If the meta value is already set on the product, the method should return false. public function test_should_convert_product_price_returns_false_when_product_is_already_converted() { // Arrange: Set up the currency used for the test. - $selected_currency = new Currency( 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); // Arrange: Set up the product, and add the meta data to it. $product = WC_Helper_Product::create_simple_product(); @@ -282,7 +290,7 @@ public function test_should_convert_product_price_returns_false_when_product_is_ // If the product is tagged a a nyp product, false should be returned. public function test_should_convert_product_price_returns_false_when_product_is_a_nyp_product() { // Arrange: Set up the currency and product used for the test. - $selected_currency = new Currency( 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); $product = WC_Helper_Product::create_simple_product(); // Arrange: Set up the mock_multi_currency method mock. @@ -301,7 +309,7 @@ public function test_should_convert_product_price_returns_false_when_product_is_ // If no tests return true, method should return true. public function test_should_convert_product_price_returns_true_when_no_matches() { // Arrange: Set up the currency and product used for the test. - $selected_currency = new Currency( 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); $product = WC_Helper_Product::create_simple_product(); // Arrange: Set up the mock_multi_currency method mock. @@ -319,7 +327,7 @@ public function test_should_convert_product_price_returns_true_when_no_matches() public function test_edit_in_cart_args() { // Arrange: Set up the currency used for the test. - $selected_currency = new Currency( 'EUR', 2.0 ); + $selected_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); // Arrange: Set up the mock_multi_currency method mock. $this->mock_multi_currency @@ -342,8 +350,8 @@ public function test_edit_in_cart_args() { public function test_get_initial_price( $initial_price, $suffix, $request, $get_selected_currency, $get_raw_conversion ) { // Arrange: Set the initial expected price and the currencies that may be used. $expected_price = $initial_price; - $selected_currency = new Currency( 'EUR', 2.0 ); - $store_currency = new Currency( 'USD', 1 ); + $selected_currency = new Currency( $this->localization_service, 'EUR', 2.0 ); + $store_currency = new Currency( $this->localization_service, 'USD', 1 ); // Arrange: Set expectations for calls to get_selected_currency method. if ( $get_selected_currency ) { diff --git a/tests/unit/multi-currency/compatibility/test-class-woocommerce-points-and-rewards.php b/tests/unit/multi-currency/compatibility/test-class-woocommerce-points-and-rewards.php index c757089b925..12e8f09c171 100644 --- a/tests/unit/multi-currency/compatibility/test-class-woocommerce-points-and-rewards.php +++ b/tests/unit/multi-currency/compatibility/test-class-woocommerce-points-and-rewards.php @@ -36,14 +36,22 @@ class WCPay_Multi_Currency_WooCommercePointsAndRewards_Tests extends WCPAY_UnitT */ private $wc_points_rewards; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ public function set_up() { parent::set_up(); - $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); - $this->mock_utils = $this->createMock( Utils::class ); + $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); + $this->mock_utils = $this->createMock( Utils::class ); + $this->localization_service = new WC_Payments_Localization_Service(); $this->wc_points_rewards = new WooCommercePointsAndRewards( $this->mock_multi_currency, $this->mock_utils ); } @@ -97,7 +105,7 @@ public function test_convert_points_ratio_skip_on_discount_backtrace() { $this->mock_multi_currency ->expects( $this->once() ) ->method( 'get_selected_currency' ) - ->willReturn( new Currency( 'EUR' ) ); + ->willReturn( new Currency( $this->localization_service, 'EUR' ) ); $this->mock_utils ->expects( $this->once() ) @@ -115,7 +123,7 @@ public function test_convert_points_ratio( $rate, $ratio, $converted_ratio ) { $this->mock_multi_currency ->expects( $this->once() ) ->method( 'get_selected_currency' ) - ->willReturn( new Currency( 'EUR', $rate ) ); + ->willReturn( new Currency( $this->localization_service, 'EUR', $rate ) ); $this->mock_utils ->expects( $this->once() ) diff --git a/tests/unit/multi-currency/test-class-analytics.php b/tests/unit/multi-currency/test-class-analytics.php index 15fa5fd3f3a..c981373a584 100644 --- a/tests/unit/multi-currency/test-class-analytics.php +++ b/tests/unit/multi-currency/test-class-analytics.php @@ -51,6 +51,13 @@ class WCPay_Multi_Currency_Analytics_Tests extends WCPAY_UnitTestCase { */ private $mock_available_currencies = []; + /** + * The localization service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ @@ -82,6 +89,8 @@ function () { $this->analytics = new Analytics( $this->mock_multi_currency ); + $this->localization_service = new WC_Payments_Localization_Service(); + remove_filter( 'user_has_cap', $cb ); } @@ -157,7 +166,7 @@ public function test_update_order_stats_data_with_multi_currency_order_without_m public function test_update_order_stats_data_with_multi_currency_order() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $args = $this->order_args_provider( 123, 0, 1, 15.50, 1.50, 0, 14.00 ); $order = wc_create_order(); @@ -172,7 +181,7 @@ public function test_update_order_stats_data_with_multi_currency_order() { public function test_update_order_stats_data_with_large_order() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $args = $this->order_args_provider( 123, 0, 1, 130500.75, 20000, 10000, 100500.75 ); $order = wc_create_order(); @@ -187,7 +196,7 @@ public function test_update_order_stats_data_with_large_order() { public function test_update_order_stats_data_with_stripe_exchange_rate() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $args = $this->order_args_provider( 123, 0, 1, 15.50, 1.50, 0, 15.00 ); $order = wc_create_order(); @@ -566,13 +575,14 @@ private function create_can_manage_woocommerce_cap_override( bool $can_manage_wo } private function get_mock_available_currencies() { + $this->localization_service = new WC_Payments_Localization_Service(); if ( empty( $this->mock_available_currencies ) ) { $this->mock_available_currencies = [ - 'GBP' => new Currency( 'GBP', 1.2 ), - 'USD' => new Currency( 'USD', 1 ), - 'EUR' => new Currency( 'EUR', 0.9 ), - 'ISK' => new Currency( 'ISK', 30.52 ), - 'NZD' => new Currency( 'NZD', 1.4 ), + 'GBP' => new Currency( $this->localization_service, 'GBP', 1.2 ), + 'USD' => new Currency( $this->localization_service, 'USD', 1 ), + 'EUR' => new Currency( $this->localization_service, 'EUR', 0.9 ), + 'ISK' => new Currency( $this->localization_service, 'ISK', 30.52 ), + 'NZD' => new Currency( $this->localization_service, 'NZD', 1.4 ), ]; } diff --git a/tests/unit/multi-currency/test-class-compatibility.php b/tests/unit/multi-currency/test-class-compatibility.php index edea21668e9..cefb478ca50 100644 --- a/tests/unit/multi-currency/test-class-compatibility.php +++ b/tests/unit/multi-currency/test-class-compatibility.php @@ -35,15 +35,23 @@ class WCPay_Multi_Currency_Compatibility_Tests extends WCPAY_UnitTestCase { */ private $mock_utils; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ public function set_up() { parent::set_up(); - $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); - $this->mock_utils = $this->createMock( Utils::class ); - $this->compatibility = new Compatibility( $this->mock_multi_currency, $this->mock_utils ); + $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); + $this->mock_utils = $this->createMock( Utils::class ); + $this->compatibility = new Compatibility( $this->mock_multi_currency, $this->mock_utils ); + $this->localization_service = new WC_Payments_Localization_Service(); } public function test_init_compatibility_classes_does_not_add_classes_if_one_enabled_currencies() { @@ -100,7 +108,7 @@ public function test_filter_woocommerce_order_query_with_order_in_default_curren $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $this->mock_utils->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) @@ -124,7 +132,7 @@ public function test_filter_woocommerce_order_query_with_order_with_no_exchange_ $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $this->mock_utils->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) @@ -145,7 +153,7 @@ public function test_filter_woocommerce_order_query_with_no_meta() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $this->mock_utils->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) @@ -169,7 +177,7 @@ public function test_filter_woocommerce_order_query() { $this->mock_multi_currency->expects( $this->once() ) ->method( 'get_default_currency' ) - ->willReturn( new Currency( 'USD', 1.0 ) ); + ->willReturn( new Currency( $this->localization_service, 'USD', 1.0 ) ); $this->mock_utils->expects( $this->once() ) ->method( 'is_call_in_backtrace' ) diff --git a/tests/unit/multi-currency/test-class-currency-switcher-block.php b/tests/unit/multi-currency/test-class-currency-switcher-block.php index 5e42e7e6659..f716f6dbe75 100644 --- a/tests/unit/multi-currency/test-class-currency-switcher-block.php +++ b/tests/unit/multi-currency/test-class-currency-switcher-block.php @@ -36,16 +36,24 @@ class WCPay_Multi_Currency_Currency_Switcher_Block_Tests extends WCPAY_UnitTestC */ protected $mock_currencies; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + public function set_up() { parent::set_up(); - $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); - $this->mock_compatibility = $this->createMock( Compatibility::class ); - $this->mock_currencies = [ - new Currency( 'USD', 1 ), - new Currency( 'CAD', 1.206823 ), - new Currency( 'GBP', 0.708099 ), - new Currency( 'EUR', 0.826381 ), + $this->mock_multi_currency = $this->createMock( MultiCurrency::class ); + $this->mock_compatibility = $this->createMock( Compatibility::class ); + $this->localization_service = new WC_Payments_Localization_Service(); + $this->mock_currencies = [ + new Currency( $this->localization_service, 'USD', 1 ), + new Currency( $this->localization_service, 'CAD', 1.206823 ), + new Currency( $this->localization_service, 'GBP', 0.708099 ), + new Currency( $this->localization_service, 'EUR', 0.826381 ), ]; $this->currency_switcher_block = new CurrencySwitcherBlock( @@ -211,8 +219,8 @@ public function test_render_currency_option_will_escape_output() { ->method( 'get_enabled_currencies' ) ->willReturn( [ - new Currency( 'USD' ), - new Currency( $currency_code, 1 ), + new Currency( $this->localization_service, 'USD' ), + new Currency( $this->localization_service, $currency_code, 1 ), ] ); @@ -267,7 +275,7 @@ public function test_widget_does_not_render_on_single_currency() { $this->mock_multi_currency ->expects( $this->once() ) ->method( 'get_enabled_currencies' ) - ->willReturn( [ new Currency( 'USD' ) ] ); + ->willReturn( [ new Currency( $this->localization_service, 'USD' ) ] ); // Act/Assert: Confirm that when calling the renger method nothing is returned. $this->assertSame( '', $this->currency_switcher_block->render_block_widget( [], '' ) ); diff --git a/tests/unit/multi-currency/test-class-currency-switcher-widget.php b/tests/unit/multi-currency/test-class-currency-switcher-widget.php index 7f6f88e299c..d18ff6d1b9c 100644 --- a/tests/unit/multi-currency/test-class-currency-switcher-widget.php +++ b/tests/unit/multi-currency/test-class-currency-switcher-widget.php @@ -32,22 +32,30 @@ class WCPay_Multi_Currency_Currency_Switcher_Widget_Tests extends WCPAY_UnitTest */ private $currency_switcher_widget; + /** + * WC_Payments_Localization_Service. + * + * @var WC_Payments_Localization_Service + */ + private $localization_service; + /** * Pre-test setup */ public function set_up() { parent::set_up(); - $this->mock_compatibility = $this->createMock( WCPay\MultiCurrency\Compatibility::class ); - $this->mock_multi_currency = $this->createMock( WCPay\MultiCurrency\MultiCurrency::class ); + $this->mock_compatibility = $this->createMock( WCPay\MultiCurrency\Compatibility::class ); + $this->mock_multi_currency = $this->createMock( WCPay\MultiCurrency\MultiCurrency::class ); + $this->localization_service = new WC_Payments_Localization_Service(); $this->mock_multi_currency ->method( 'get_enabled_currencies' ) ->willReturn( [ - new Currency( 'USD' ), - new Currency( 'CAD', 1.2 ), - new Currency( 'EUR', 0.8 ), - new Currency( 'CHF', 1.1 ), + new Currency( $this->localization_service, 'USD' ), + new Currency( $this->localization_service, 'CAD', 1.2 ), + new Currency( $this->localization_service, 'EUR', 0.8 ), + new Currency( $this->localization_service, 'CHF', 1.1 ), ] ); @@ -100,7 +108,7 @@ public function test_widget_renders_hidden_input() { public function test_widget_selects_selected_currency() { $this->mock_compatibility->method( 'should_disable_currency_switching' )->willReturn( false ); - $this->mock_multi_currency->method( 'get_selected_currency' )->willReturn( new Currency( 'CAD' ) ); + $this->mock_multi_currency->method( 'get_selected_currency' )->willReturn( new Currency( $this->localization_service, 'CAD' ) ); $this->expectOutputRegex( '/