diff --git a/.eslintignore b/.eslintignore index 590187b9d5e..e558812a35a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -10,3 +10,7 @@ vendor/* release/* tests/e2e/docker* tests/e2e/deps* + +# We'll delete the directory and its contents as part of https://github.com/Automattic/woocommerce-payments/issues/9722 . +# ignoring it because we're temporariily cleaning it up. +client/tokenized-payment-request diff --git a/.github/workflows/e2e-pull-request.yml b/.github/workflows/e2e-pull-request.yml index aa4748b37d0..4ed61a3882b 100644 --- a/.github/workflows/e2e-pull-request.yml +++ b/.github/workflows/e2e-pull-request.yml @@ -18,23 +18,23 @@ on: description: "Branch to be used for running tests" env: - E2E_GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }} - WCP_DEV_TOOLS_REPO: ${{ secrets.WCP_DEV_TOOLS_REPO }} - WCP_DEV_TOOLS_BRANCH: 'trunk' - WCP_SERVER_REPO: ${{ secrets.WCP_SERVER_REPO }} - WC_SUBSCRIPTIONS_REPO: ${{ secrets.WC_SUBSCRIPTIONS_REPO }} - E2E_BLOG_ID: ${{ secrets.E2E_BLOG_ID }} - E2E_BLOG_TOKEN: ${{ secrets.E2E_BLOG_TOKEN }} - E2E_USER_TOKEN: ${{ secrets.E2E_USER_TOKEN }} - WC_E2E_SCREENSHOTS: 1 - E2E_SLACK_CHANNEL: ${{ secrets.E2E_SLACK_CHANNEL }} - E2E_SLACK_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }} - E2E_USE_LOCAL_SERVER: false - E2E_RESULT_FILEPATH: 'tests/e2e/results.json' - WCPAY_USE_BUILD_ARTIFACT: ${{ inputs.wcpay-use-build-artifact }} - WCPAY_ARTIFACT_DIRECTORY: 'zipfile' - NODE_ENV: 'test' - FORCE_E2E_DEPS_SETUP: true + E2E_GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }} + WCP_DEV_TOOLS_REPO: ${{ secrets.WCP_DEV_TOOLS_REPO }} + WCP_DEV_TOOLS_BRANCH: 'trunk' + TRANSACT_PLATFORM_SERVER_REPO: ${{ secrets.TRANSACT_PLATFORM_SERVER_REPO }} + WC_SUBSCRIPTIONS_REPO: ${{ secrets.WC_SUBSCRIPTIONS_REPO }} + E2E_BLOG_ID: ${{ secrets.E2E_BLOG_ID }} + E2E_BLOG_TOKEN: ${{ secrets.E2E_BLOG_TOKEN }} + E2E_USER_TOKEN: ${{ secrets.E2E_USER_TOKEN }} + WC_E2E_SCREENSHOTS: 1 + E2E_SLACK_CHANNEL: ${{ secrets.E2E_SLACK_CHANNEL }} + E2E_SLACK_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }} + E2E_USE_LOCAL_SERVER: false + E2E_RESULT_FILEPATH: 'tests/e2e/results.json' + WCPAY_USE_BUILD_ARTIFACT: ${{ inputs.wcpay-use-build-artifact }} + WCPAY_ARTIFACT_DIRECTORY: 'zipfile' + NODE_ENV: 'test' + FORCE_E2E_DEPS_SETUP: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/e2e-pw-pull-request.yml b/.github/workflows/e2e-pw-pull-request.yml index 0cc22f767c2..da6765fb51b 100644 --- a/.github/workflows/e2e-pw-pull-request.yml +++ b/.github/workflows/e2e-pw-pull-request.yml @@ -21,7 +21,7 @@ env: E2E_GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }} WCP_DEV_TOOLS_REPO: ${{ secrets.WCP_DEV_TOOLS_REPO }} WCP_DEV_TOOLS_BRANCH: 'trunk' - WCP_SERVER_REPO: ${{ secrets.WCP_SERVER_REPO }} + TRANSACT_PLATFORM_SERVER_REPO: ${{ secrets.TRANSACT_PLATFORM_SERVER_REPO }} WC_SUBSCRIPTIONS_REPO: ${{ secrets.WC_SUBSCRIPTIONS_REPO }} E2E_BLOG_ID: ${{ secrets.E2E_BLOG_ID }} E2E_BLOG_TOKEN: ${{ secrets.E2E_BLOG_TOKEN }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 04e85183f3e..1d1f0b1bd71 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -10,22 +10,22 @@ on: workflow_dispatch: env: - E2E_GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }} - WCP_DEV_TOOLS_REPO: ${{ secrets.WCP_DEV_TOOLS_REPO }} - WCP_DEV_TOOLS_BRANCH: 'trunk' - WCP_SERVER_REPO: ${{ secrets.WCP_SERVER_REPO }} - WC_SUBSCRIPTIONS_REPO: ${{ secrets.WC_SUBSCRIPTIONS_REPO }} - E2E_BLOG_ID: ${{ secrets.E2E_BLOG_ID }} - E2E_BLOG_TOKEN: ${{ secrets.E2E_BLOG_TOKEN }} - E2E_USER_TOKEN: ${{ secrets.E2E_USER_TOKEN }} - WC_E2E_SCREENSHOTS: 1 - E2E_SLACK_CHANNEL: ${{ secrets.E2E_SLACK_CHANNEL }} - E2E_SLACK_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }} - E2E_USE_LOCAL_SERVER: false - E2E_RESULT_FILEPATH: 'tests/e2e/results.json' - WC_MIN_SUPPORTED_VERSION: '7.6.0' - NODE_ENV: 'test' - FORCE_E2E_DEPS_SETUP: true + E2E_GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }} + WCP_DEV_TOOLS_REPO: ${{ secrets.WCP_DEV_TOOLS_REPO }} + WCP_DEV_TOOLS_BRANCH: 'trunk' + TRANSACT_PLATFORM_SERVER_REPO: ${{ secrets.TRANSACT_PLATFORM_SERVER_REPO }} + WC_SUBSCRIPTIONS_REPO: ${{ secrets.WC_SUBSCRIPTIONS_REPO }} + E2E_BLOG_ID: ${{ secrets.E2E_BLOG_ID }} + E2E_BLOG_TOKEN: ${{ secrets.E2E_BLOG_TOKEN }} + E2E_USER_TOKEN: ${{ secrets.E2E_USER_TOKEN }} + WC_E2E_SCREENSHOTS: 1 + E2E_SLACK_CHANNEL: ${{ secrets.E2E_SLACK_CHANNEL }} + E2E_SLACK_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }} + E2E_USE_LOCAL_SERVER: false + E2E_RESULT_FILEPATH: 'tests/e2e/results.json' + WC_MIN_SUPPORTED_VERSION: '7.6.0' + NODE_ENV: 'test' + FORCE_E2E_DEPS_SETUP: true jobs: generate-matrix: diff --git a/.github/workflows/php-compatibility.yml b/.github/workflows/php-compatibility.yml index 5d018dabc37..41d87e5b1db 100644 --- a/.github/workflows/php-compatibility.yml +++ b/.github/workflows/php-compatibility.yml @@ -1,7 +1,8 @@ name: PHP Compatibility on: - pull_request + #pull_request # Workflow disabled temporarily until PHP Compatibility fixes are in place + workflow_dispatch concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/assets/images/illustrations/setup.svg b/assets/images/illustrations/setup.svg new file mode 100644 index 00000000000..b5c8abde96d --- /dev/null +++ b/assets/images/illustrations/setup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/changelog.txt b/changelog.txt index 745c2fe24be..d97e8e7ba85 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,8 @@ *** WooPayments Changelog *** += 8.5.1 - 2024-11-25 = +* Fix - fix: remove "test mode" badge from shortcode checkout. + = 8.5.0 - 2024-11-13 = * Add - Add country-specific test card numbers for credit card testing * Add - Add risk level information to the fraud and risk box on the order details page. diff --git a/changelog/add-5316-payout-trace-id b/changelog/add-5316-payout-trace-id new file mode 100644 index 00000000000..a5e90413a86 --- /dev/null +++ b/changelog/add-5316-payout-trace-id @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add Bank reference key column in Payout reports. This will help reconcile WooPayments Payouts with bank statements. diff --git a/changelog/add-9556-set-support-phone-mandatory b/changelog/add-9556-set-support-phone-mandatory new file mode 100644 index 00000000000..e777eaae4a6 --- /dev/null +++ b/changelog/add-9556-set-support-phone-mandatory @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Set the support phone field as mandatory in the settings page. diff --git a/changelog/as-fix-ece-tax-based-billing-address b/changelog/as-fix-ece-tax-based-billing-address new file mode 100644 index 00000000000..ab6fc7be8fd --- /dev/null +++ b/changelog/as-fix-ece-tax-based-billing-address @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ensure ECE is displayed correctly taking into account the tax settings. \ No newline at end of file diff --git a/changelog/chore-ece-states-class-constants b/changelog/chore-ece-states-class-constants new file mode 100644 index 00000000000..5ff6dfd3ab8 --- /dev/null +++ b/changelog/chore-ece-states-class-constants @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: chore: rename Payment_Request_Button_States to Express_Checkout_Element_States to reflect its usage + + diff --git a/changelog/chore-remove-redundant-init-for-payfororder b/changelog/chore-remove-redundant-init-for-payfororder new file mode 100644 index 00000000000..3ca1a909911 --- /dev/null +++ b/changelog/chore-remove-redundant-init-for-payfororder @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Avoid getting the appearance for pay for order page with the wrong appearance key. diff --git a/changelog/dev-4293-address-additional-union-types b/changelog/dev-4293-address-additional-union-types new file mode 100644 index 00000000000..361b062f85f --- /dev/null +++ b/changelog/dev-4293-address-additional-union-types @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Fix return types diff --git a/changelog/dev-4293-enforce-proper-return-types-for-methodsfunctions-get_order_from_event_body_intent_id b/changelog/dev-4293-enforce-proper-return-types-for-methodsfunctions-get_order_from_event_body_intent_id new file mode 100644 index 00000000000..c31e1d66df8 --- /dev/null +++ b/changelog/dev-4293-enforce-proper-return-types-for-methodsfunctions-get_order_from_event_body_intent_id @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Ensure proper return types in the webhook processing service. diff --git a/changelog/dev-allow-redirect-to-settings-page-from-wcpay-connect b/changelog/dev-allow-redirect-to-settings-page-from-wcpay-connect new file mode 100644 index 00000000000..3fca0c1ff3e --- /dev/null +++ b/changelog/dev-allow-redirect-to-settings-page-from-wcpay-connect @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Allow redirect to the settings page from WCPay connect diff --git a/changelog/fix-7399 b/changelog/fix-7399 new file mode 100644 index 00000000000..91b8aed706d --- /dev/null +++ b/changelog/fix-7399 @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Ensure dispute urls in order notes are urlencoded + + diff --git a/changelog/fix-9709-load-stripe-asynchronously b/changelog/fix-9709-load-stripe-asynchronously new file mode 100644 index 00000000000..0e95dac6d8d --- /dev/null +++ b/changelog/fix-9709-load-stripe-asynchronously @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Add support to load stripe js asynchronously when it is not immediately available in the global scope. diff --git a/changelog/fix-9784-ece-tracks-events b/changelog/fix-9784-ece-tracks-events new file mode 100644 index 00000000000..4c9ab158fa3 --- /dev/null +++ b/changelog/fix-9784-ece-tracks-events @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix ECE Tracks events not triggering when WooPay is disabled. diff --git a/changelog/fix-no-bnpl-subscriptions b/changelog/fix-no-bnpl-subscriptions new file mode 100644 index 00000000000..7c9035f8736 --- /dev/null +++ b/changelog/fix-no-bnpl-subscriptions @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Small fix for BNPL messaging element. + + diff --git a/changelog/fix-pmme-appearance-blocks b/changelog/fix-pmme-appearance-blocks new file mode 100644 index 00000000000..bfde3b89bdd --- /dev/null +++ b/changelog/fix-pmme-appearance-blocks @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Use paragraph selector instead of label for pmme appearance diff --git a/changelog/fix-remove-shortcode-test-mode-badge-from-label b/changelog/fix-remove-shortcode-test-mode-badge-from-label new file mode 100644 index 00000000000..80d0813d077 --- /dev/null +++ b/changelog/fix-remove-shortcode-test-mode-badge-from-label @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: remove "test mode" badge from shortcode checkout. diff --git a/changelog/fix-woopay-component-spacing b/changelog/fix-woopay-component-spacing new file mode 100644 index 00000000000..0939c834ad9 --- /dev/null +++ b/changelog/fix-woopay-component-spacing @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix WooPay component spacing. diff --git a/changelog/fix-woopay-trial-subscriptions b/changelog/fix-woopay-trial-subscriptions new file mode 100644 index 00000000000..58c43b05c16 --- /dev/null +++ b/changelog/fix-woopay-trial-subscriptions @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix WooPay trial subscriptions purchases. diff --git a/changelog/refactor-tokenized-ece-base-implementation b/changelog/refactor-tokenized-ece-base-implementation new file mode 100644 index 00000000000..8402a60a94f --- /dev/null +++ b/changelog/refactor-tokenized-ece-base-implementation @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: feat: tokenized cart ECE shortcode base implementation. + + diff --git a/changelog/update-phpcompatibility-latest b/changelog/update-phpcompatibility-latest new file mode 100644 index 00000000000..5765c8249d8 --- /dev/null +++ b/changelog/update-phpcompatibility-latest @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Update phpcompatibility to develop version to get sniffs for PHP 8. No need to include it in the changelog since it is a dev task that doesn't impact WooPayments. + + diff --git a/changelog/update-server-container-name b/changelog/update-server-container-name new file mode 100644 index 00000000000..cb9580f8a22 --- /dev/null +++ b/changelog/update-server-container-name @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Updates server container name used by E2E tests + + diff --git a/client/cart/blocks/product-details.js b/client/cart/blocks/product-details.js index 87e807d354e..f52d4270aa6 100644 --- a/client/cart/blocks/product-details.js +++ b/client/cart/blocks/product-details.js @@ -11,6 +11,7 @@ import { select } from '@wordpress/data'; * Internal dependencies */ import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; +import { useStripeAsync } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'utils/checkout'; import WCPayAPI from '../../checkout/api'; import request from '../../checkout/utils/request'; @@ -47,6 +48,8 @@ const ProductDetail = ( { cart, context } ) => { const [ fontRules ] = useState( getFontRulesFromPage() ); + const stripe = useStripeAsync( api ); + useEffect( () => { async function generateUPEAppearance() { // Generate UPE input styles. @@ -63,6 +66,10 @@ const ProductDetail = ( { cart, context } ) => { } }, [ appearance ] ); + if ( ! stripe ) { + return null; + } + if ( Object.keys( appearance ).length === 0 ) { return null; } @@ -91,8 +98,6 @@ const ProductDetail = ( { cart, context } ) => { countryCode: country, // Customer's country or base country of the store. }; - const stripe = api.getStripe(); - return (
+ setTimeout( resolve, waitInterval ) + ); + currentWaitTime += waitInterval; + if ( currentWaitTime > maxWaitTime ) { + throw new Error( 'Stripe object not found' ); + } + } + return this.__getStripe( forceAccountRequest ); + } + /** * Generates a new instance of Stripe. * * @param {boolean} forceAccountRequest True to instantiate the Stripe object with the merchant's account key. * @return {Object} The Stripe Object. */ - getStripe( forceAccountRequest = false ) { + __getStripe( forceAccountRequest = false ) { const { publishableKey, accountId, @@ -105,17 +121,15 @@ export default class WCPayAPI { * * @return {Promise} Promise with the Stripe object or an error. */ - loadStripeForExpressCheckout() { - return new Promise( ( resolve ) => { - try { - // Force Stripe to be loadded with the connected account. - resolve( this.getStripe( true ) ); - } catch ( error ) { - // In order to avoid showing console error publicly to users, - // we resolve instead of rejecting when there is an error. - resolve( { error } ); - } - } ); + async loadStripeForExpressCheckout() { + // Force Stripe to be loadded with the connected account. + try { + return this.getStripe( true ); + } catch ( error ) { + // In order to avoid showing console error publicly to users, + // we resolve instead of rejecting when there is an error. + return { error }; + } } /** @@ -160,7 +174,7 @@ export default class WCPayAPI { orderId = orderIdPartials[ 0 ]; } - const confirmPaymentOrSetup = () => { + const confirmPaymentOrSetup = async () => { const { locale, publishableKey } = this.options; const accountIdForIntentConfirmation = getConfig( 'accountIdForIntentConfirmation' @@ -168,8 +182,9 @@ export default class WCPayAPI { // If this is a setup intent we're not processing a woopay payment so we can // use the regular getStripe function. + const stripe = await this.getStripe(); if ( isSetupIntent ) { - return this.getStripe().handleNextAction( { + return stripe.handleNextAction( { clientSecret: clientSecret, } ); } @@ -186,7 +201,8 @@ export default class WCPayAPI { // When not dealing with a setup intent or woopay we need to force an account // specific request in Stripe. - return this.getStripe( true ).handleNextAction( { + const stripeWithForcedAccountRequest = await this.getStripe( true ); + return stripeWithForcedAccountRequest.handleNextAction( { clientSecret: clientSecret, } ); }; @@ -251,32 +267,34 @@ export default class WCPayAPI { * @param {string} paymentMethodId The ID of the payment method. * @return {Promise} The final promise for the request to the server. */ - setupIntent( paymentMethodId ) { - return this.request( getConfig( 'ajaxUrl' ), { + async setupIntent( paymentMethodId ) { + const response = await this.request( getConfig( 'ajaxUrl' ), { action: 'create_setup_intent', 'wcpay-payment-method': paymentMethodId, _ajax_nonce: getConfig( 'createSetupIntentNonce' ), - } ).then( ( response ) => { - if ( ! response.success ) { - throw response.data.error; - } + } ); - if ( response.data.status === 'succeeded' ) { - // No need for further authentication. - return response.data; - } + if ( ! response.success ) { + throw response.data.error; + } - return this.getStripe() - .confirmCardSetup( response.data.client_secret ) - .then( ( confirmedSetupIntent ) => { - const { setupIntent, error } = confirmedSetupIntent; - if ( error ) { - throw error; - } + if ( response.data.status === 'succeeded' ) { + // No need for further authentication. + return response.data; + } - return setupIntent; - } ); - } ); + const stripe = await this.getStripe(); + + const confirmedSetupIntent = await stripe.confirmCardSetup( + response.data.client_secret + ); + + const { setupIntent, error } = confirmedSetupIntent; + if ( error ) { + throw error; + } + + return setupIntent; } /** diff --git a/client/checkout/api/test/index.test.js b/client/checkout/api/test/index.test.js index 06fbf00765b..8ec819ea4c0 100644 --- a/client/checkout/api/test/index.test.js +++ b/client/checkout/api/test/index.test.js @@ -55,6 +55,45 @@ const mockAppearance = { }; describe( 'WCPayAPI', () => { + describe( 'getStripe', () => { + afterEach( () => { + jest.useRealTimers(); + window.Stripe = undefined; + } ); + + test( 'waits for Stripe to be available in the global scope', async () => { + jest.useFakeTimers(); + const api = new WCPayAPI( {}, request ); + let stripeInstance = null; + + api.getStripe().then( ( result ) => { + stripeInstance = result; + } ); + + jest.runOnlyPendingTimers(); + await Promise.resolve(); + + expect( stripeInstance ).toBeNull(); + + window.Stripe = function Stripe() {}; + + jest.runOnlyPendingTimers(); + await Promise.resolve(); + + jest.runOnlyPendingTimers(); + await Promise.resolve(); + + expect( stripeInstance ).toBeInstanceOf( window.Stripe ); + } ); + + test( 'resolves immediately if Stripe is already available', async () => { + const api = new WCPayAPI( {}, request ); + window.Stripe = function Stripe() {}; + const stripeInstance = await api.getStripe(); + expect( stripeInstance ).toBeInstanceOf( window.Stripe ); + } ); + } ); + test( 'does not initialize woopay if already requesting', async () => { buildAjaxURL.mockReturnValue( 'https://example.org/' ); getConfig.mockImplementation( ( key ) => { diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 8376d0b3e8a..cdb3d105861 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -103,9 +103,8 @@ Object.entries( enabledPaymentMethodsConfig ) const isAvailableInTheCountry = ! isRestrictedInAnyCountry || upeConfig.countries.includes( billingCountry ); - return ( - isAvailableInTheCountry && !! api.getStripeForUPE( upeName ) - ); + // We used to check if stripe was loaded with `getStripeForUPE`, but we can't guarantee it will be loaded synchronously. + return isAvailableInTheCountry; }, paymentMethodId: upeMethods[ upeName ], // see .wc-block-checkout__payment-method styles in blocks/style.scss diff --git a/client/checkout/blocks/payment-elements.js b/client/checkout/blocks/payment-elements.js index bedbf4777cc..f782ba13545 100644 --- a/client/checkout/blocks/payment-elements.js +++ b/client/checkout/blocks/payment-elements.js @@ -11,6 +11,7 @@ import { StoreNotice } from '@woocommerce/blocks-checkout'; */ import './style.scss'; import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; +import { useStripeForUPE } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'wcpay/utils/checkout'; import { useFingerprint } from './hooks'; import { LoadableBlock } from 'wcpay/components/loadable'; @@ -18,7 +19,8 @@ import PaymentProcessor from './payment-processor'; import { getPaymentMethodTypes } from 'wcpay/checkout/utils/upe'; const PaymentElements = ( { api, ...props } ) => { - const stripe = api.getStripeForUPE( props.paymentMethodId ); + const stripeForUPE = useStripeForUPE( api, props.paymentMethodId ); + const [ errorMessage, setErrorMessage ] = useState( null ); const [ paymentProcessorLoadErrorMessage, @@ -59,10 +61,14 @@ const PaymentElements = ( { api, ...props } ) => { props.paymentMethodId, ] ); + if ( ! stripeForUPE ) { + return ; + } + return (
@@ -117,7 +124,7 @@ export default ( { appearance={ appearance } > { .fn() .mockResolvedValue( { paymentMethod: {} } ); mockApi = { - getStripeForUPE: () => ( { - createPaymentMethod: mockCreatePaymentMethod, - } ), + getStripeForUPE: () => + Promise.resolve( { + createPaymentMethod: mockCreatePaymentMethod, + } ), }; } ); diff --git a/client/checkout/classic/event-handlers.js b/client/checkout/classic/event-handlers.js index 0bcfc06f8d1..fe53b9b2a88 100644 --- a/client/checkout/classic/event-handlers.js +++ b/client/checkout/classic/event-handlers.js @@ -122,7 +122,7 @@ jQuery( function ( $ ) { } } ); - if ( $addPaymentMethodForm.length || $payForOrderForm.length ) { + if ( $addPaymentMethodForm.length ) { maybeMountStripePaymentElement( 'add_payment_method' ); } @@ -169,43 +169,55 @@ jQuery( function ( $ ) { async function injectStripePMMEContainers() { const bnplMethods = [ 'affirm', 'afterpay_clearpay', 'klarna' ]; + const labelBase = 'payment_method_woocommerce_payments_'; const paymentMethods = getUPEConfig( 'paymentMethodsConfig' ); const paymentMethodsKeys = Object.keys( paymentMethods ); const cartData = await api.pmmeGetCartData(); for ( const method of paymentMethodsKeys ) { if ( bnplMethods.includes( method ) ) { + const targetLabel = document.querySelector( + `label[for="${ labelBase }${ method }"]` + ); const containerID = `stripe-pmme-container-${ method }`; - const container = document.getElementById( containerID ); - if ( ! container ) { - continue; + if ( document.getElementById( containerID ) ) { + document.getElementById( containerID ).innerHTML = ''; } - container.innerHTML = ''; - container.dataset.paymentMethodType = method; - - const currentCountry = - cartData?.billing_address?.country || - getUPEConfig( 'storeCountry' ); - if ( - paymentMethods[ method ]?.countries.length === 0 || - paymentMethods[ method ]?.countries?.includes( - currentCountry - ) - ) { - await mountStripePaymentMethodMessagingElement( - api, - container, - { - amount: cartData?.totals?.total_price, - currency: cartData?.totals?.currency_code, - decimalPlaces: - cartData?.totals?.currency_minor_unit, - country: currentCountry, - }, - 'shortcode_checkout' - ); + if ( targetLabel ) { + let container = document.getElementById( containerID ); + if ( ! container ) { + container = document.createElement( 'span' ); + container.id = containerID; + container.dataset.paymentMethodType = method; + container.classList.add( 'stripe-pmme-container' ); + targetLabel.appendChild( container ); + } + + const currentCountry = + cartData?.billing_address?.country || + getUPEConfig( 'storeCountry' ); + + if ( + paymentMethods[ method ]?.countries.length === 0 || + paymentMethods[ method ]?.countries?.includes( + currentCountry + ) + ) { + await mountStripePaymentMethodMessagingElement( + api, + container, + { + amount: cartData?.totals?.total_price, + currency: cartData?.totals?.currency_code, + decimalPlaces: + cartData?.totals?.currency_minor_unit, + country: currentCountry, + }, + 'shortcode_checkout' + ); + } } } } diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index 839e53a2ca7..56595bebf93 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -166,7 +166,7 @@ function isMissingRequiredAddressFieldsForBNPL( params, paymentMethodType ) { * @param {string} paymentMethodType The type of Stripe payment method to create. * @return {Promise} A promise that resolves with the created Stripe payment method. */ -function createStripePaymentMethod( +async function createStripePaymentMethod( api, elements, jQueryForm, @@ -235,9 +235,9 @@ function createStripePaymentMethod( delete params.billing_details.address; } - return api - .getStripeForUPE( paymentMethodType ) - .createPaymentMethod( { elements, params: params } ); + const stripe = await api.getStripeForUPE( paymentMethodType ); + + return stripe.createPaymentMethod( { elements, params: params } ); } /** @@ -274,9 +274,9 @@ async function createStripePaymentElement( fonts: getFontRulesFromPage(), }; - const elements = api - .getStripeForUPE( paymentMethodType ) - .elements( options ); + const stripe = await api.getStripeForUPE( paymentMethodType ); + + const elements = stripe.elements( options ); const createdStripePaymentElement = elements.create( 'payment', { ...getUpeSettings(), wallets: { @@ -503,8 +503,8 @@ export async function mountStripePaymentMethodMessagingElement( const appearance = await initializeAppearance( api, location ); try { - const paymentMethodMessagingElement = api - .getStripe() + const stripe = await api.getStripe(); + const paymentMethodMessagingElement = stripe .elements( { appearance: appearance, fonts: getFontRulesFromPage(), diff --git a/client/checkout/classic/style.scss b/client/checkout/classic/style.scss index 36a3d29bd94..c9a6535c12f 100644 --- a/client/checkout/classic/style.scss +++ b/client/checkout/classic/style.scss @@ -30,117 +30,66 @@ border: none; } -.woopayments-rich-payment-method-label { - // this will be displayed only on specific scenarios. Otherwise, the "legacy" label will be displayed. - display: none; -} - #payment .payment_methods { - li[class*='payment_method_woocommerce_payments'] > label > img { + li[class*='payment_method_woocommerce_payments'] label img { float: right; border: 0; padding: 0; height: 24px !important; max-height: 24px !important; } +} - &.wc_payment_methods, - &.woocommerce-PaymentMethods { - li[class*='payment_method_woocommerce_payments'] { - display: grid; - grid-template-columns: 0fr 0fr 1fr; - grid-template-rows: max-content; - - .woopayments-plain-payment-method-label { - display: none; - } - - > input[name='payment_method'] { - &:checked ~ label { - .payment-method-title { - margin-right: 8px; // 8px gap between .payment-method-title and .test-mode.badge - } +li.wc_payment_method:has( .input-radio:not( :checked ) + + label + .stripe-pmme-container ) { + display: grid; + grid-template-columns: min-content 1fr; + grid-template-rows: auto auto; + align-items: baseline; + + .input-radio { + grid-row: 1; + grid-column: 1; + } - .test-mode.badge { - display: inline-block; // hiding the badge when the payment method is not selected - } + label { + grid-column: 2; + grid-row: 1; + } - .stripe-pmme-container { - display: none; - } - } - } + img { + grid-row: 1 / span 2; + align-self: center; + } - > label { - grid-column: 3; - margin-bottom: 0; - } + .stripe-pmme-container { + width: 100%; + grid-column: 1; + grid-row-start: 2; + pointer-events: none; + } - > div.payment_box { - grid-area: 2 / 1 / 3 / 4; - } + .payment_box { + flex: 0 0 100%; + grid-row: 2; + grid-column: 1 / span 2; + } +} - > label:has( .woopayments-rich-payment-method-label ) { - display: inline-flex; - align-items: center; - width: 100%; +li.wc_payment_method:has( .input-radio:checked + + label + .stripe-pmme-container ) { + display: block; - > img { - display: none; // we'll display the image inside `.woopayments-rich-payment-method-label`, instead. - } + .input-radio:checked { + + label { + .stripe-pmme-container { + display: none; } - .woopayments-rich-payment-method-label { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - margin-bottom: 0; - flex-grow: 1; - - .label-title-container { - display: block; - } - - .payment-method-title { - white-space: normal; // Allows wrapping if text is too long - vertical-align: middle; - } - - .test-mode.badge { - display: none; - background-color: #fff2d7; - border-radius: 4px; - padding: 4px 6px; - font-size: 12px; - font-weight: 400; - line-height: 16px; - color: #4d3716; - vertical-align: middle; - white-space: nowrap; // Prevents the badge text from wrapping - } - - img { - border: 0; - padding: 0; - height: 24px !important; - max-height: 24px !important; - justify-self: end; - margin: 3px 0; // ensuring the images don't appear squished when all the payment methods are rendered next to each others, like in Elementor. - align-self: center; - } - - .stripe-pmme-container { - &:empty { - display: none; // hides container if empty, without affecting alignment - } - - margin-left: 0.25em; // WooCommerce Core will add a   on the left of the payment method's label - this spacing ensures that at least it's consistently aligned. - pointer-events: none; - grid-column: 1 / 2; - grid-row: 2 / 3; - align-self: start; - width: 100%; - } + img { + grid-column: 2; } } } diff --git a/client/checkout/classic/test/payment-processing.test.js b/client/checkout/classic/test/payment-processing.test.js index 7230e8790ff..1013b8a0c40 100644 --- a/client/checkout/classic/test/payment-processing.test.js +++ b/client/checkout/classic/test/payment-processing.test.js @@ -105,10 +105,12 @@ const mockCreatePaymentMethod = jest.fn().mockResolvedValue( { const apiMock = { saveUPEAppearance: jest.fn().mockResolvedValue( {} ), - getStripeForUPE: jest.fn( () => ( { - elements: mockElements, - createPaymentMethod: mockCreatePaymentMethod, - } ) ), + getStripeForUPE: jest.fn( () => + Promise.resolve( { + elements: mockElements, + createPaymentMethod: mockCreatePaymentMethod, + } ) + ), setupIntent: jest.fn().mockResolvedValue( {} ), }; diff --git a/client/checkout/stripe-link/index.js b/client/checkout/stripe-link/index.js index 9a9c63acf4a..45f4feabf36 100644 --- a/client/checkout/stripe-link/index.js +++ b/client/checkout/stripe-link/index.js @@ -40,17 +40,16 @@ const transformStripeLinkAddress = ( address ) => { }; }; -const enableStripeLinkPaymentMethod = ( options ) => { +const enableStripeLinkPaymentMethod = async ( options ) => { const emailField = document.getElementById( options.emailId ); if ( ! emailField ) { return; } + const stripe = await options.api.getStripe(); // https://stripe.com/docs/payments/link/autofill-modal - const linkAutofill = options.api - .getStripe() - .linkAutofillModal( options.elements ); + const linkAutofill = stripe.linkAutofillModal( options.elements ); emailField.addEventListener( 'keyup', ( event ) => { linkAutofill.launch( { email: event.target.value } ); diff --git a/client/checkout/stripe-link/test/index.test.js b/client/checkout/stripe-link/test/index.test.js index 28f2cc10669..b8d907c3508 100644 --- a/client/checkout/stripe-link/test/index.test.js +++ b/client/checkout/stripe-link/test/index.test.js @@ -4,54 +4,50 @@ import enableStripeLinkPaymentMethod from '..'; import WCPayAPI from 'wcpay/checkout/api'; -jest.mock( 'wcpay/checkout/api', () => { - const mockOn = jest.fn(); - const mockLaunch = jest.fn(); +const mockOn = jest.fn(); +const mockLaunch = jest.fn(); - const mockLaunchAutofillModal = jest.fn( () => { - return { - launch: mockLaunch, - on: mockOn, - }; - } ); - - const mockedStripe = jest.fn( () => { - return { - linkAutofillModal: mockLaunchAutofillModal, - }; - } ); +const mockLaunchAutofillModal = jest.fn( () => { + return { + launch: mockLaunch, + on: mockOn, + }; +} ); - return jest.fn().mockImplementation( () => { - return { - getStripe: mockedStripe, - }; +const mockedStripe = jest.fn( () => { + return Promise.resolve( { + linkAutofillModal: mockLaunchAutofillModal, } ); } ); +jest.mock( 'wcpay/checkout/api', () => + jest.fn().mockImplementation( () => ( { + getStripe: mockedStripe, + } ) ) +); + const billingEmail = 'example@example.com'; describe( 'Stripe Link elements behavior', () => { - test( 'Should stop if emailId is not found', () => { - enableStripeLinkPaymentMethod( { + test( 'Should stop if emailId is not found', async () => { + await enableStripeLinkPaymentMethod( { emailId: 'not_existing_email@example.com', } ); - expect( - WCPayAPI().getStripe().linkAutofillModal - ).not.toHaveBeenCalled(); + expect( mockLaunchAutofillModal ).not.toHaveBeenCalled(); } ); - test( 'Should call linkAutofillModal when email is present', () => { + test( 'Should call linkAutofillModal when email is present', async () => { createStripeLinkElements(); - enableStripeLinkPaymentMethod( { + await enableStripeLinkPaymentMethod( { api: WCPayAPI(), emailId: 'billing_email', onAutofill: () => null, onButtonShow: () => null, } ); - expect( WCPayAPI().getStripe().linkAutofillModal ).toHaveBeenCalled(); + expect( mockLaunchAutofillModal ).toHaveBeenCalled(); } ); - test( 'Should add keyup event listener to email input', () => { + test( 'Should add keyup event listener to email input', async () => { createStripeLinkElements(); const billingEmailInput = document.getElementById( 'billing_email' ); const addEventListenerSpy = jest.spyOn( @@ -59,7 +55,7 @@ describe( 'Stripe Link elements behavior', () => { 'addEventListener' ); - enableStripeLinkPaymentMethod( { + await enableStripeLinkPaymentMethod( { api: WCPayAPI(), emailId: 'billing_email', onAutofill: () => null, @@ -71,15 +67,15 @@ describe( 'Stripe Link elements behavior', () => { 'keyup', expect.any( Function ) ); - expect( - WCPayAPI().getStripe().linkAutofillModal().launch - ).toHaveBeenCalledWith( { email: billingEmail } ); + expect( mockLaunch ).toHaveBeenCalledWith( { + email: billingEmail, + } ); } ); - test( 'Stripe Link button should call onButtonShow configuration value', () => { + test( 'Stripe Link button should call onButtonShow configuration value', async () => { createStripeLinkElements(); const handleButtonShow = jest.fn(); - enableStripeLinkPaymentMethod( { + await enableStripeLinkPaymentMethod( { api: WCPayAPI(), emailId: 'billing_email', onAutofill: () => null, diff --git a/client/checkout/upe-styles/index.js b/client/checkout/upe-styles/index.js index cc6a64deebb..5c775caf43e 100644 --- a/client/checkout/upe-styles/index.js +++ b/client/checkout/upe-styles/index.js @@ -23,6 +23,7 @@ export const appearanceSelectors = { appendTarget: '.woocommerce-billing-fields__field-wrapper', upeThemeInputSelector: '#billing_first_name', upeThemeLabelSelector: '.woocommerce-checkout .form-row label', + upeThemeTextSelector: '.woocommerce-checkout .form-row', rowElement: 'p', validClasses: [ 'form-row' ], invalidClasses: [ @@ -46,6 +47,7 @@ export const appearanceSelectors = { appendTarget: '#contact-fields', upeThemeInputSelector: '.wc-block-components-text-input #email', upeThemeLabelSelector: '.wc-block-components-text-input label', + upeThemeTextSelector: '.wc-block-components-text-input', rowElement: 'div', validClasses: [ 'wc-block-components-text-input', 'is-active' ], invalidClasses: [ 'wc-block-components-text-input', 'has-error' ], @@ -73,6 +75,7 @@ export const appearanceSelectors = { appendTarget: '.product .cart .quantity', upeThemeInputSelector: '.product .cart .quantity .qty', upeThemeLabelSelector: '.product .cart .quantity label', + upeThemeTextSelector: '.product .cart .quantity', rowElement: 'div', validClasses: [ 'input-text' ], invalidClasses: [ 'input-text', 'has-error' ], @@ -91,6 +94,7 @@ export const appearanceSelectors = { appendTarget: '.cart .quantity', upeThemeInputSelector: '.cart .quantity .qty', upeThemeLabelSelector: '.cart .quantity label', + upeThemeTextSelector: '.cart .quantity', rowElement: 'div', validClasses: [ 'input-text' ], invalidClasses: [ 'input-text', 'has-error' ], @@ -111,6 +115,7 @@ export const appearanceSelectors = { upeThemeInputSelector: '.wc-block-cart .wc-block-components-quantity-selector .wc-block-components-quantity-selector__input', upeThemeLabelSelector: '.wc-block-components-text-input', + upeThemeTextSelector: '.wc-block-components-text-input', rowElement: 'div', validClasses: [ 'wc-block-components-text-input' ], invalidClasses: [ 'wc-block-components-text-input', 'has-error' ], @@ -133,6 +138,7 @@ export const appearanceSelectors = { appendTarget: '.woocommerce-billing-fields__field-wrapper', upeThemeInputSelector: '#billing_first_name', upeThemeLabelSelector: '.woocommerce-checkout .form-row label', + upeThemeTextSelector: '.woocommerce-checkout .form-row', rowElement: 'p', validClasses: [ 'form-row' ], invalidClasses: [ @@ -476,6 +482,11 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { '.Label' ); + const paragraphRules = getFieldStyles( + selectors.upeThemeTextSelector, + '.Text' + ); + const tabRules = getFieldStyles( selectors.upeThemeInputSelector, '.Tab' ); const selectedTabRules = getFieldStyles( selectors.hiddenInput, @@ -505,9 +516,9 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { ); const globalRules = { colorBackground: backgroundColor, - colorText: labelRules.color, - fontFamily: labelRules.fontFamily, - fontSizeBase: labelRules.fontSize, + colorText: paragraphRules.color, + fontFamily: paragraphRules.fontFamily, + fontSizeBase: paragraphRules.fontSize, }; const isFloatingLabel = elementsLocation === 'blocks_checkout'; @@ -528,8 +539,8 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { '.Tab--selected': selectedTabRules, '.TabIcon:hover': tabIconHoverRules, '.TabIcon--selected': selectedTabIconRules, - '.Text': labelRules, - '.Text--redirect': labelRules, + '.Text': paragraphRules, + '.Text--redirect': paragraphRules, } ) ), }; diff --git a/client/checkout/upe-styles/upe-styles.js b/client/checkout/upe-styles/upe-styles.js index fda1c987f09..b578960317e 100644 --- a/client/checkout/upe-styles/upe-styles.js +++ b/client/checkout/upe-styles/upe-styles.js @@ -55,6 +55,7 @@ const borderOutlineBackgroundProps = [ ]; const upeSupportedProperties = { '.Label': [ ...paddingColorProps, ...textFontTransitionProps ], + '.Text': [ ...paddingColorProps, ...textFontTransitionProps ], '.Input': [ ...paddingColorProps, ...textFontTransitionProps, @@ -112,4 +113,6 @@ export const upeRestrictedProperties = { '.TabLabel': upeSupportedProperties[ '.TabLabel' ], '.Block': upeSupportedProperties[ '.Block' ], '.Container': upeSupportedProperties[ '.Container' ], + '.Text': upeSupportedProperties[ '.Text' ], + '.Text--redirect': upeSupportedProperties[ '.Text' ], }; diff --git a/client/checkout/woopay/index.js b/client/checkout/woopay/index.js index f6c8902a514..51217c7aa30 100644 --- a/client/checkout/woopay/index.js +++ b/client/checkout/woopay/index.js @@ -46,8 +46,6 @@ const renderSaveUserSection = () => { checkoutPageSaveUserContainer, paymentOptions.nextSibling ); - - paymentOptions.classList.add( 'is-woopay' ); } } diff --git a/client/components/woopay/save-user/checkout-page-save-user.js b/client/components/woopay/save-user/checkout-page-save-user.js index f0563246552..45295c5c170 100644 --- a/client/components/woopay/save-user/checkout-page-save-user.js +++ b/client/components/woopay/save-user/checkout-page-save-user.js @@ -26,7 +26,6 @@ import request from '../../../checkout/utils/request'; import useSelectedPaymentMethod from '../hooks/use-selected-payment-method'; import { recordUserEvent } from 'tracks'; import './style.scss'; -import { compare } from 'compare-versions'; const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { const errorId = 'invalid-woopay-phone-number'; @@ -57,12 +56,6 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { ); const viewportWidth = window.document.documentElement.clientWidth; const viewportHeight = window.document.documentElement.clientHeight; - const wooCommerceVersionString = window.wcSettings?.wcVersion; - const wcVersionGreaterThan91 = compare( - wooCommerceVersionString, - '9.1', - '>=' - ); useEffect( () => { if ( ! isBlocksCheckout ) { @@ -291,10 +284,7 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { } return ( - +
{ id="save_user_in_woopay" value="true" className={ `save-details-checkbox ${ - wcVersionGreaterThan91 - ? 'without-margin-right' - : '' - } ${ isBlocksCheckout ? 'wc-block-components-checkbox__input' : '' diff --git a/client/components/woopay/save-user/container.js b/client/components/woopay/save-user/container.js index 470103044ac..7a032fe795f 100644 --- a/client/components/woopay/save-user/container.js +++ b/client/components/woopay/save-user/container.js @@ -3,19 +3,11 @@ */ import { __ } from '@wordpress/i18n'; -const Container = ( { - children, - isBlocksCheckout, - wcVersionGreaterThan91, -} ) => { +const Container = ( { children, isBlocksCheckout } ) => { if ( ! isBlocksCheckout ) return children; return ( <> -
+

{ __( 'Save my info' ) }

{ children }
diff --git a/client/components/woopay/save-user/style.scss b/client/components/woopay/save-user/style.scss index 8561cd4e62b..0e07ed4d611 100644 --- a/client/components/woopay/save-user/style.scss +++ b/client/components/woopay/save-user/style.scss @@ -7,37 +7,6 @@ } } -.is-mobile, -.is-small { - .woopay-save-new-user-container.wc-version-greater-than-91::after { - background: currentColor; - box-shadow: -50vw 0 0 0 currentColor, 50vw 0 0 0 currentColor; - content: ''; - height: 1px; - opacity: 0.11; - width: 100%; - margin-top: 22px; - } -} - -.is-medium, -.is-large { - .woopay-save-new-user-container.wc-version-greater-than-91 { - border-bottom: 1px solid hsla( 0, 0%, 7%, 0.11 ); - margin-bottom: 48px; - } -} - -@media ( max-width: 600px ) { - .is-mobile, - .is-small { - .wc-block-components-form - .wc-block-components-checkout-step.is-woopay::after { - height: 0; - } - } -} - .woopay-save-new-user-container { .save-details { .wc-block-components-text-input input:-webkit-autofill { @@ -45,15 +14,6 @@ } } - &.wc-version-greater-than-91 { - @media ( min-width: 601px ), - ( min-width: 566px ) and ( max-width: 568px ) { - .save-details { - margin-bottom: 48px; - } - } - } - .save-details-header { display: flex; align-items: flex-start; @@ -87,10 +47,6 @@ text-indent: 0; } - input:not( .without-margin-right ) { - margin-right: $gap-small; - } - span { @include breakpoint( '>960px' ) { margin-right: 1.25rem; diff --git a/client/connect-account-page/index.tsx b/client/connect-account-page/index.tsx index ac1668de0a8..faa5d94311c 100644 --- a/client/connect-account-page/index.tsx +++ b/client/connect-account-page/index.tsx @@ -27,11 +27,12 @@ import Incentive from './incentive'; import InfoNotice from './info-notice-modal'; import OnboardingLocationCheckModal from './modal'; import LogoImg from 'assets/images/woopayments.svg?asset'; +import SetupImg from 'assets/images/illustrations/setup.svg?asset'; import strings from './strings'; import './style.scss'; import InlineNotice from 'components/inline-notice'; import { WooPaymentMethodsLogos } from 'components/payment-method-logos'; -import WooPaymentsLogo from 'assets/images/logo.svg?asset'; +import WooLogo from 'assets/images/woo-logo.svg?asset'; import { sanitizeHTML } from 'wcpay/utils/sanitize'; import { isInTestModeOnboarding } from 'wcpay/utils'; import ResetAccountModal from 'wcpay/overview/modal/reset-account'; @@ -52,18 +53,19 @@ const TestDriveLoader: React.FunctionComponent< { progress: number; } > = ( { progress } ) => ( - + + + setup + + - { __( - 'Creating your sandbox account', - 'woocommerce-payments' - ) } + { __( 'Finishing payments setup', 'woocommerce-payments' ) } { __( - 'In just a few moments, you will be ready to test payments on your store.' + "In just a few moments, you'll be ready to test payments on your store." ) } @@ -193,6 +195,8 @@ const ConnectAccountPage: React.FC = () => { 'wcpay-sandbox-success': 'true', source: determineTrackingSource(), from: 'WCPAY_CONNECT', + redirect_to_settings_page: + urlParams.get( 'redirect_to_settings_page' ) || '', } ); } else { setTimeout( checkAccountStatus, 2000 ); diff --git a/client/connect-account-page/style.scss b/client/connect-account-page/style.scss index 41e278c3bc1..96efc3493c2 100644 --- a/client/connect-account-page/style.scss +++ b/client/connect-account-page/style.scss @@ -249,12 +249,12 @@ z-index: 999999; text-align: center; - img { + img.logo { position: absolute; - height: 44px; - width: 167px; + height: 40px; + width: 40px; top: 18px; - left: calc( 50% - 84px ); + left: 36px; } } } diff --git a/client/deposits/list/index.tsx b/client/deposits/list/index.tsx index c991e7e7ec3..b74c14bca61 100644 --- a/client/deposits/list/index.tsx +++ b/client/deposits/list/index.tsx @@ -97,6 +97,11 @@ const getColumns = ( sortByDate?: boolean ): DepositsTableHeader[] => [ screenReaderLabel: __( 'Bank account', 'woocommerce-payments' ), isLeftAligned: true, }, + { + key: 'bankReferenceKey', + label: __( 'Bank reference key', 'woocommerce-payments' ), + screenReaderLabel: __( 'Bank reference key', 'woocommerce-payments' ), + }, ]; export const DepositsList = (): JSX.Element => { @@ -165,6 +170,10 @@ export const DepositsList = (): JSX.Element => { value: deposit.bankAccount, display: clickable( deposit.bankAccount ), }, + bankReferenceKey: { + value: deposit.bank_reference_key, + display: clickable( deposit.bank_reference_key ?? 'N/A' ), + }, }; return columns.map( ( { key } ) => data[ key ] || { display: null } ); diff --git a/client/deposits/list/test/__snapshots__/index.tsx.snap b/client/deposits/list/test/__snapshots__/index.tsx.snap index 86e84ecd1d1..9e15ae2f735 100644 --- a/client/deposits/list/test/__snapshots__/index.tsx.snap +++ b/client/deposits/list/test/__snapshots__/index.tsx.snap @@ -313,6 +313,22 @@ exports[`Deposits list renders correctly a single deposit 1`] = ` Bank account + + + + Bank reference key + + + + + mock_reference_key + + + + + mock_reference_key + + + + + mock_reference_key + + @@ -949,6 +1001,22 @@ exports[`Deposits list renders correctly with multiple currencies 1`] = ` Bank account + + + + Bank reference key + + + + + mock_reference_key + + + + + mock_reference_key + + + + + mock_reference_key + + diff --git a/client/deposits/list/test/index.tsx b/client/deposits/list/test/index.tsx index d70561fb6d4..628dc670346 100644 --- a/client/deposits/list/test/index.tsx +++ b/client/deposits/list/test/index.tsx @@ -53,6 +53,7 @@ const mockDeposits = [ status: 'paid', bankAccount: 'MOCK BANK •••• 1234 (USD)', currency: 'USD', + bank_reference_key: 'mock_reference_key', } as CachedDeposit, { id: 'po_mock2', @@ -62,6 +63,7 @@ const mockDeposits = [ status: 'pending', bankAccount: 'MOCK BANK •••• 1234 (USD)', currency: 'USD', + bank_reference_key: 'mock_reference_key', } as CachedDeposit, { id: 'po_mock3', @@ -71,6 +73,7 @@ const mockDeposits = [ status: 'paid', bankAccount: 'MOCK BANK •••• 1234 (USD)', currency: 'USD', + bank_reference_key: 'mock_reference_key', } as CachedDeposit, ]; @@ -287,6 +290,7 @@ describe( 'Deposits list', () => { 'Amount', 'Status', '"Bank account"', + '"Bank reference key"', ]; const csvContent = mockDownloadCSVFile.mock.calls[ 0 ][ 1 ]; @@ -332,6 +336,9 @@ describe( 'Deposits list', () => { expect( csvFirstDeposit[ 5 ] ).toBe( `"${ displayFirstDeposit[ 4 ] }"` ); // bank account + expect( csvFirstDeposit[ 6 ] ).toBe( + `${ displayFirstDeposit[ 5 ] }` + ); // bank reference key } ); test( 'should fetch export after confirmation when download button is selected for unfiltered exports larger than 1000.', async () => { diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index 4f916680deb..6f36a3e6b59 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -194,7 +194,7 @@ jQuery( ( $ ) => { * * @param {Object} options ECE options. */ - startExpressCheckoutElement: ( options ) => { + startExpressCheckoutElement: async ( options ) => { const getShippingRates = () => { if ( ! options.requestShipping ) { return []; @@ -237,7 +237,9 @@ jQuery( ( $ ) => { return; } - const elements = api.getStripe().elements( { + const stripe = await api.getStripe(); + + const elements = stripe.elements( { mode: options?.mode ?? 'payment', amount: options?.total, currency: options?.currency, @@ -336,7 +338,7 @@ jQuery( ( $ ) => { return onConfirmHandler( api, - api.getStripe(), + stripe, elements, wcpayECE.completePayment, wcpayECE.abortPayment, @@ -534,7 +536,7 @@ jQuery( ( $ ) => { /** * Initialize event handlers and UI state */ - init: () => { + init: async () => { if ( getExpressCheckoutData( 'button_context' ) === 'pay_for_order' ) { @@ -554,7 +556,7 @@ jQuery( ( $ ) => { return; } - wcpayECE.startExpressCheckoutElement( { + await wcpayECE.startExpressCheckoutElement( { mode: 'payment', total, currency: getExpressCheckoutData( 'checkout' ) @@ -569,7 +571,7 @@ jQuery( ( $ ) => { } else if ( getExpressCheckoutData( 'button_context' ) === 'product' ) { - wcpayECE.startExpressCheckoutElement( { + await wcpayECE.startExpressCheckoutElement( { mode: 'payment', total: getExpressCheckoutData( 'product' )?.total.amount, currency: getExpressCheckoutData( 'product' )?.currency, @@ -585,24 +587,23 @@ jQuery( ( $ ) => { } else { // If this is the cart or checkout page, we need to request the // cart details. - api.expressCheckoutECEGetCartDetails().then( ( cart ) => { - if ( cart.total.amount === 0 ) { - expressCheckoutButtonUi.hideContainer(); - expressCheckoutButtonUi.getButtonSeparator().hide(); - } else { - wcpayECE.startExpressCheckoutElement( { - mode: 'payment', - total: cart.total.amount, - currency: getExpressCheckoutData( 'checkout' ) - ?.currency_code, - requestShipping: cart.needs_shipping, - requestPhone: - getExpressCheckoutData( 'checkout' ) - ?.needs_payer_phone ?? false, - displayItems: cart.displayItems, - } ); - } - } ); + const cart = await api.expressCheckoutECEGetCartDetails(); + if ( cart.total.amount === 0 ) { + expressCheckoutButtonUi.hideContainer(); + expressCheckoutButtonUi.getButtonSeparator().hide(); + } else { + await wcpayECE.startExpressCheckoutElement( { + mode: 'payment', + total: cart.total.amount, + currency: getExpressCheckoutData( 'checkout' ) + ?.currency_code, + requestShipping: cart.needs_shipping, + requestPhone: + getExpressCheckoutData( 'checkout' ) + ?.needs_payer_phone ?? false, + displayItems: cart.displayItems, + } ); + } } // After initializing a new express checkout button, we need to reset the paymentAborted flag. diff --git a/client/express-checkout/utils/index.ts b/client/express-checkout/utils/index.ts index 5a3dec295b5..cfbc2b25b2b 100644 --- a/client/express-checkout/utils/index.ts +++ b/client/express-checkout/utils/index.ts @@ -37,6 +37,7 @@ export interface WCPayExpressCheckoutParams { currency_code: string; needs_payer_phone: boolean; needs_shipping: boolean; + currency_decimals: number; }; /** @@ -54,6 +55,9 @@ export interface WCPayExpressCheckoutParams { platform_tracker: string; shipping: string; update_shipping: string; + tokenized_cart_nonce: string; + tokenized_cart_session_nonce: string; + store_api_nonce: string; }; /** diff --git a/client/hooks/use-stripe-async.js b/client/hooks/use-stripe-async.js new file mode 100644 index 00000000000..b9bdcadaa63 --- /dev/null +++ b/client/hooks/use-stripe-async.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { useEffect, useState } from 'react'; + +export function useStripeAsync( api, forceAccountRequest = false ) { + const [ stripe, setStripe ] = useState( null ); + + useEffect( () => { + ( async () => { + const initializedStripe = await api.getStripe( + forceAccountRequest + ); + setStripe( initializedStripe ); + } )(); + }, [ api, forceAccountRequest ] ); + + return stripe; +} + +export function useStripeForUPE( api, paymentMethodId ) { + const [ stripe, setStripe ] = useState( null ); + + useEffect( () => { + ( async () => { + const stripeForUPE = await api.getStripeForUPE( paymentMethodId ); + setStripe( stripeForUPE ); + } )(); + }, [ api, paymentMethodId ] ); + + return stripe; +} diff --git a/client/product-details/bnpl-site-messaging/index.js b/client/product-details/bnpl-site-messaging/index.js index 1bbe51e0edf..a435b16a0f5 100644 --- a/client/product-details/bnpl-site-messaging/index.js +++ b/client/product-details/bnpl-site-messaging/index.js @@ -99,8 +99,9 @@ export const initializeBnplSiteMessaging = async () => { fonts: getFontRulesFromPage(), }; - paymentMessageElement = api - .getStripe() + const stripe = await api.getStripe(); + + paymentMessageElement = stripe .elements( elementsOptions ) .create( 'paymentMethodMessaging', options ); paymentMessageElement.mount( '#payment-method-message' ); diff --git a/client/settings/support-phone-input/index.js b/client/settings/support-phone-input/index.js index 5ae4c4466da..afb77404949 100644 --- a/client/settings/support-phone-input/index.js +++ b/client/settings/support-phone-input/index.js @@ -28,6 +28,12 @@ const SupportPhoneInput = ( { setInputVallid } ) => { isTestModeOnboarding && supportPhone === '+10000000000'; const [ isPhoneValid, setPhoneValidity ] = useState( true ); + if ( supportPhone === '' ) { + supportPhoneError = __( + 'Support phone number cannot be empty.', + 'woocommerce-payments' + ); + } if ( ! isTestPhoneValid && ! isPhoneValid && ! isEmptyPhoneValid ) { supportPhoneError = __( 'Please enter a valid phone number.', diff --git a/client/settings/support-phone-input/test/support-phone-input.test.js b/client/settings/support-phone-input/test/support-phone-input.test.js index 02e4ce0b92c..227f02f52d7 100644 --- a/client/settings/support-phone-input/test/support-phone-input.test.js +++ b/client/settings/support-phone-input/test/support-phone-input.test.js @@ -76,14 +76,14 @@ describe( 'SupportPhoneInput', () => { ); } ); - it( 'no error message for empty phone input when it has not been set', async () => { + it( 'error message for empty phone input when it has not been set', async () => { useAccountBusinessSupportPhone.mockReturnValue( [ '', jest.fn() ] ); const { container } = render( ); expect( - container.querySelector( '.components-notice.is-error' ) - ).toBeNull(); + container.querySelector( '.components-notice.is-error' ).textContent + ).toEqual( 'Support phone number cannot be empty.' ); } ); it( 'displays the error message for invalid phone', async () => { diff --git a/client/tokenized-payment-request/test/cart-api.test.js b/client/tokenized-express-checkout/__tests__/cart-api.test.js similarity index 85% rename from client/tokenized-payment-request/test/cart-api.test.js rename to client/tokenized-express-checkout/__tests__/cart-api.test.js index 46f3dab0c0a..b60bffd6299 100644 --- a/client/tokenized-payment-request/test/cart-api.test.js +++ b/client/tokenized-express-checkout/__tests__/cart-api.test.js @@ -6,28 +6,28 @@ import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import PaymentRequestCartApi from '../cart-api'; +import ExpressCheckoutCartApi from '../cart-api'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); -global.wcpayPaymentRequestParams = {}; -global.wcpayPaymentRequestParams.nonce = {}; -global.wcpayPaymentRequestParams.nonce.store_api_nonce = +global.wcpayExpressCheckoutParams = {}; +global.wcpayExpressCheckoutParams.nonce = {}; +global.wcpayExpressCheckoutParams.nonce.store_api_nonce = 'global_store_api_nonce'; -global.wcpayPaymentRequestParams.nonce.tokenized_cart_nonce = +global.wcpayExpressCheckoutParams.nonce.tokenized_cart_nonce = 'global_tokenized_cart_nonce'; -global.wcpayPaymentRequestParams.nonce.tokenized_cart_session_nonce = +global.wcpayExpressCheckoutParams.nonce.tokenized_cart_session_nonce = 'global_tokenized_cart_session_nonce'; -global.wcpayPaymentRequestParams.checkout = {}; -global.wcpayPaymentRequestParams.checkout.currency_code = 'USD'; +global.wcpayExpressCheckoutParams.checkout = {}; +global.wcpayExpressCheckoutParams.checkout.currency_code = 'USD'; -describe( 'PaymentRequestCartApi', () => { +describe( 'ExpressCheckoutCartApi', () => { afterEach( () => { jest.resetAllMocks(); } ); it( 'should allow to create an anonymous cart for a specific class instance, without affecting other instances', async () => { - global.wcpayPaymentRequestParams.button_context = 'product'; + global.wcpayExpressCheckoutParams.button_context = 'product'; const headers = new Headers(); headers.append( 'X-WooPayments-Tokenized-Cart-Session', @@ -39,8 +39,8 @@ describe( 'PaymentRequestCartApi', () => { json: () => Promise.resolve( {} ), } ); - const api = new PaymentRequestCartApi(); - const anotherApi = new PaymentRequestCartApi(); + const api = new ExpressCheckoutCartApi(); + const anotherApi = new ExpressCheckoutCartApi(); api.useSeparateCart(); await api.getCart(); @@ -120,12 +120,12 @@ describe( 'PaymentRequestCartApi', () => { } ); it( 'should call `/cart/update-customer` with the global headers if the cart is not anonymous', async () => { - global.wcpayPaymentRequestParams.button_context = 'cart'; + global.wcpayExpressCheckoutParams.button_context = 'cart'; apiFetch.mockResolvedValue( { headers: new Headers(), json: () => Promise.resolve( {} ), } ); - const api = new PaymentRequestCartApi(); + const api = new ExpressCheckoutCartApi(); await api.updateCustomer( { billing_address: { last_name: 'Last' }, @@ -150,14 +150,14 @@ describe( 'PaymentRequestCartApi', () => { } ); it( 'should store received header information for subsequent usage', async () => { - global.wcpayPaymentRequestParams.button_context = 'cart'; + global.wcpayExpressCheckoutParams.button_context = 'cart'; const headers = new Headers(); headers.append( 'Nonce', 'nonce-value' ); apiFetch.mockResolvedValue( { headers, json: () => Promise.resolve( {} ), } ); - const api = new PaymentRequestCartApi(); + const api = new ExpressCheckoutCartApi(); await api.getCart(); diff --git a/client/tokenized-payment-request/test/order-api.js b/client/tokenized-express-checkout/__tests__/order-api.js similarity index 87% rename from client/tokenized-payment-request/test/order-api.js rename to client/tokenized-express-checkout/__tests__/order-api.js index 268101654c8..8f95a0887b8 100644 --- a/client/tokenized-payment-request/test/order-api.js +++ b/client/tokenized-express-checkout/__tests__/order-api.js @@ -6,22 +6,22 @@ import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import PaymentRequestOrderApi from '../order-api'; +import ExpressCheckoutOrderApi from '../order-api'; jest.mock( '@wordpress/api-fetch', () => jest.fn() ); -global.wcpayPaymentRequestParams = {}; -global.wcpayPaymentRequestParams.nonce = {}; -global.wcpayPaymentRequestParams.nonce.store_api_nonce = +global.wcpayExpressCheckoutParams = {}; +global.wcpayExpressCheckoutParams.nonce = {}; +global.wcpayExpressCheckoutParams.nonce.store_api_nonce = 'global_store_api_nonce'; -describe( 'PaymentRequestOrderApi', () => { +describe( 'ExpressCheckoutOrderApi', () => { afterEach( () => { jest.resetAllMocks(); } ); it( 'gets order data with the provided arguments', async () => { - const api = new PaymentRequestOrderApi( { + const api = new ExpressCheckoutOrderApi( { orderId: '1', key: 'key_123', billingEmail: 'cheese@toast.com', @@ -40,7 +40,7 @@ describe( 'PaymentRequestOrderApi', () => { } ); it( 'places an order', async () => { - const api = new PaymentRequestOrderApi( { + const api = new ExpressCheckoutOrderApi( { orderId: '1', key: 'key_123', billingEmail: 'cheese@toast.com', @@ -74,7 +74,7 @@ describe( 'PaymentRequestOrderApi', () => { } ); it( 'places an order with the previous API request data', async () => { - const api = new PaymentRequestOrderApi( { + const api = new ExpressCheckoutOrderApi( { orderId: '1', key: 'key_123', billingEmail: 'cheese@toast.com', diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-component.js b/client/tokenized-express-checkout/blocks/components/express-checkout-component.js index bccfee2eb93..9975b56d406 100644 --- a/client/tokenized-express-checkout/blocks/components/express-checkout-component.js +++ b/client/tokenized-express-checkout/blocks/components/express-checkout-component.js @@ -95,10 +95,10 @@ const ExpressCheckoutComponent = ( { } ); const onClickHandler = ! isPreview ? onButtonClick : () => {}; const onShippingAddressChange = ( event ) => - shippingAddressChangeHandler( api, event, elements ); + shippingAddressChangeHandler( event, elements ); const onShippingRateChange = ( event ) => - shippingRateChangeHandler( api, event, elements ); + shippingRateChangeHandler( event, elements ); const onElementsReady = ( event ) => { const paymentMethodContainer = document.getElementById( diff --git a/client/tokenized-payment-request/cart-api.js b/client/tokenized-express-checkout/cart-api.js similarity index 92% rename from client/tokenized-payment-request/cart-api.js rename to client/tokenized-express-checkout/cart-api.js index 52fcc25b46d..4e277f73c80 100644 --- a/client/tokenized-payment-request/cart-api.js +++ b/client/tokenized-express-checkout/cart-api.js @@ -10,9 +10,9 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ -import { getPaymentRequestData } from './frontend-utils'; +import { getExpressCheckoutData } from './utils'; -export default class PaymentRequestCartApi { +export default class ExpressCheckoutCartApi { // Used on product pages to interact with an anonymous cart. // This anonymous cart is separate from the customer's cart, which might contain additional products. // This functionality is also useful to calculate product/shipping pricing (and shipping needs) @@ -30,23 +30,23 @@ export default class PaymentRequestCartApi { ...options, parse: false, 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( + // `wcpayExpressCheckoutParams` will always be defined if this file is needed. + // If there's an issue with it, ask yourself why this file is queued and `wcpayExpressCheckoutParams` isn't present. + currency: getExpressCheckoutData( 'checkout' ).currency_code.toUpperCase(), } ), headers: { // the Store API nonce, which could later be overwritten in subsequent requests. - Nonce: getPaymentRequestData( 'nonce' ).store_api_nonce, + Nonce: getExpressCheckoutData( 'nonce' ).store_api_nonce, // needed for validation of address data, etc. 'X-WooPayments-Tokenized-Cart-Nonce': - getPaymentRequestData( 'nonce' ).tokenized_cart_nonce || + getExpressCheckoutData( 'nonce' ).tokenized_cart_nonce || undefined, // necessary to validate any request made to the backend from the PDP. 'X-WooPayments-Tokenized-Cart-Session-Nonce': - getPaymentRequestData( 'button_context' ) === 'product' - ? getPaymentRequestData( 'nonce' ) + getExpressCheckoutData( 'button_context' ) === 'product' + ? getExpressCheckoutData( 'nonce' ) .tokenized_cart_session_nonce : undefined, ...this.cartRequestHeaders, @@ -170,7 +170,7 @@ export default class PaymentRequestCartApi { method: 'POST', path: '/wc/store/v1/cart/add-item', data: applyFilters( - 'wcpay.payment-request.cart-add-item', + 'wcpay.express-checkout.cart-add-item', productData ), } ); diff --git a/client/tokenized-payment-request/compatibility/wc-deposits.js b/client/tokenized-express-checkout/compatibility/wc-deposits.js similarity index 100% rename from client/tokenized-payment-request/compatibility/wc-deposits.js rename to client/tokenized-express-checkout/compatibility/wc-deposits.js diff --git a/client/tokenized-payment-request/compatibility/wc-order-attribution.js b/client/tokenized-express-checkout/compatibility/wc-order-attribution.js similarity index 100% rename from client/tokenized-payment-request/compatibility/wc-order-attribution.js rename to client/tokenized-express-checkout/compatibility/wc-order-attribution.js diff --git a/client/tokenized-payment-request/compatibility/wc-product-variations.js b/client/tokenized-express-checkout/compatibility/wc-product-variations.js similarity index 100% rename from client/tokenized-payment-request/compatibility/wc-product-variations.js rename to client/tokenized-express-checkout/compatibility/wc-product-variations.js diff --git a/client/tokenized-express-checkout/event-handlers.js b/client/tokenized-express-checkout/event-handlers.js index 2d1345ff752..c2d3ad557ef 100644 --- a/client/tokenized-express-checkout/event-handlers.js +++ b/client/tokenized-express-checkout/event-handlers.js @@ -3,59 +3,86 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; + /** * Internal dependencies */ -import { - getErrorMessageFromNotice, - normalizeOrderData, - normalizePayForOrderData, - normalizeShippingAddress, - normalizeLineItems, - getExpressCheckoutData, -} from './utils'; +import { getErrorMessageFromNotice, getExpressCheckoutData } from './utils'; import { trackExpressCheckoutButtonClick, trackExpressCheckoutButtonLoad, } from './tracking'; +import ExpressCheckoutCartApi from './cart-api'; +import { + transformStripePaymentMethodForStoreApi, + transformStripeShippingAddressForStoreApi, +} from './transformers/stripe-to-wc'; +import { + transformCartDataForDisplayItems, + transformCartDataForShippingRates, + transformPrice, +} from './transformers/wc-to-stripe'; -export const shippingAddressChangeHandler = async ( api, event, elements ) => { +let cartApi = new ExpressCheckoutCartApi(); +export const setCartApiHandler = ( handler ) => ( cartApi = handler ); +export const getCartApiHandler = () => cartApi; + +export const shippingAddressChangeHandler = async ( event, elements ) => { try { - const response = await api.expressCheckoutECECalculateShippingOptions( - normalizeShippingAddress( event.address ) - ); + // Please note that the `event.address` 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 cartApi.updateCustomer( { + shipping_address: transformStripeShippingAddressForStoreApi( + event.name, + event.address + ), + } ); - if ( response.result === 'success' ) { - elements.update( { - amount: response.total.amount, - } ); - event.resolve( { - shippingRates: response.shipping_options, - lineItems: normalizeLineItems( response.displayItems ), - } ); - } else { + const shippingRates = transformCartDataForShippingRates( cartData ); + + // when no shipping options are returned, the API still returns a 200 status code. + // We need to ensure that shipping options are present - otherwise the ECE dialog won't update correctly. + if ( shippingRates.length === 0 ) { event.reject(); + + return; } - } catch ( e ) { + + elements.update( { + amount: transformPrice( + parseInt( cartData.totals.total_price, 10 ) - + parseInt( cartData.totals.total_refund || 0, 10 ), + cartData.totals + ), + } ); + event.resolve( { + shippingRates: transformCartDataForShippingRates( cartData ), + lineItems: transformCartDataForDisplayItems( cartData ), + } ); + } catch ( error ) { event.reject(); } }; -export const shippingRateChangeHandler = async ( api, event, elements ) => { +export const shippingRateChangeHandler = async ( event, elements ) => { try { - const response = await api.expressCheckoutECEUpdateShippingDetails( - event.shippingRate - ); + const cartData = await cartApi.selectShippingRate( { + package_id: 0, + rate_id: event.shippingRate.id, + } ); - if ( response.result === 'success' ) { - elements.update( { amount: response.total.amount } ); - event.resolve( { - lineItems: normalizeLineItems( response.displayItems ), - } ); - } else { - event.reject(); - } - } catch ( e ) { + elements.update( { + amount: transformPrice( + parseInt( cartData.totals.total_price, 10 ) - + parseInt( cartData.totals.total_refund || 0, 10 ), + cartData.totals + ), + } ); + event.resolve( { + lineItems: transformCartDataForDisplayItems( cartData ), + } ); + } catch ( error ) { event.reject(); } }; @@ -66,8 +93,7 @@ export const onConfirmHandler = async ( elements, completePayment, abortPayment, - event, - order = 0 // Order ID for the pay for order flow. + event ) => { const { error: submitError } = await elements.submit(); if ( submitError ) { @@ -84,30 +110,39 @@ export const onConfirmHandler = async ( try { // Kick off checkout processing step. - let orderResponse; - if ( ! order ) { - orderResponse = await api.expressCheckoutECECreateOrder( - normalizeOrderData( event, paymentMethod.id ) - ); - } else { - orderResponse = await api.expressCheckoutECEPayForOrder( - order, - normalizePayForOrderData( event, paymentMethod.id ) - ); - } + const orderResponse = await cartApi.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, + paymentMethod.id + ), + extensions: applyFilters( + 'wcpay.payment-request.cart-place-order-extension-data', + {} + ), + } ); - if ( orderResponse.result !== 'success' ) { + if ( orderResponse.payment_result.payment_status !== 'success' ) { return abortPayment( event, - getErrorMessageFromNotice( orderResponse.messages ) + getErrorMessageFromNotice( + orderResponse.message ?? + orderResponse.payment_result?.payment_details.find( + ( detail ) => detail.key === 'errorMessage' + )?.value ?? + '' + ) ); } - const confirmationRequest = api.confirmIntent( orderResponse.redirect ); + const confirmationRequest = api.confirmIntent( + orderResponse.payment_result.redirect_url + ); // `true` means there is no intent to confirm. if ( confirmationRequest === true ) { - completePayment( orderResponse.redirect ); + completePayment( orderResponse.payment_result.redirect_url ); } else { const redirectUrl = await confirmationRequest; @@ -116,7 +151,10 @@ export const onConfirmHandler = async ( } catch ( e ) { return abortPayment( event, - e.message ?? + getErrorMessageFromNotice( e.message ) || + e.payment_result?.payment_details.find( + ( detail ) => detail.key === 'errorMessage' + )?.value || __( 'There was a problem processing the order.', 'woocommerce-payments' diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js index 4f916680deb..940aa1462b8 100644 --- a/client/tokenized-express-checkout/index.js +++ b/client/tokenized-express-checkout/index.js @@ -1,4 +1,4 @@ -/* global jQuery, wcpayExpressCheckoutParams, wcpayECEPayForOrderParams */ +/* global jQuery, wcpayExpressCheckoutParams */ import { __ } from '@wordpress/i18n'; import { debounce } from 'lodash'; @@ -7,11 +7,13 @@ import { debounce } from 'lodash'; */ import WCPayAPI from '../checkout/api'; import '../checkout/express-checkout-buttons.scss'; +import './compatibility/wc-deposits'; +import './compatibility/wc-order-attribution'; +import './compatibility/wc-product-variations'; import { getExpressCheckoutButtonAppearance, getExpressCheckoutButtonStyleSettings, getExpressCheckoutData, - normalizeLineItems, displayLoginConfirmation, } from './utils'; import { @@ -23,8 +25,17 @@ import { onReadyHandler, shippingAddressChangeHandler, shippingRateChangeHandler, + setCartApiHandler, + getCartApiHandler, } from './event-handlers'; +import ExpressCheckoutOrderApi from './order-api'; +import { getUPEConfig } from 'wcpay/utils/checkout'; import expressCheckoutButtonUi from './button-ui'; +import { + transformCartDataForDisplayItems, + transformCartDataForShippingRates, + transformPrice, +} from 'wcpay/tokenized-express-checkout/transformers/wc-to-stripe'; jQuery( ( $ ) => { // Don't load if blocks checkout is being loaded. @@ -57,6 +68,16 @@ jQuery( ( $ ) => { } ); + if ( getExpressCheckoutData( 'button_context' ) === 'pay_for_order' ) { + setCartApiHandler( + new ExpressCheckoutOrderApi( { + orderId: getUPEConfig( 'order_id' ), + key: getUPEConfig( 'key' ), + billingEmail: getUPEConfig( 'billing_email' ), + } ) + ); + } + expressCheckoutButtonUi.init( { elementId: '#wcpay-express-checkout-element', $separator: jQuery( '#wcpay-express-checkout-button-separator' ), @@ -186,6 +207,7 @@ jQuery( ( $ ) => { } } ); + // TODO ~FR: replace with cartApi return api.expressCheckoutECEAddToCart( data ); }, @@ -194,53 +216,12 @@ jQuery( ( $ ) => { * * @param {Object} options ECE options. */ - startExpressCheckoutElement: ( options ) => { - const getShippingRates = () => { - if ( ! options.requestShipping ) { - return []; - } - - if ( - getExpressCheckoutData( 'button_context' ) === 'product' - ) { - // Despite the name of the property, this seems to be just a single option that's not in an array. - const { - shippingOptions: shippingOption, - } = getExpressCheckoutData( 'product' ); - - return [ - { - id: shippingOption.id, - amount: shippingOption.amount, - displayName: shippingOption.label, - }, - ]; - } - - return options.displayItems - .filter( ( i ) => i.key === 'total_shipping' ) - .map( ( i ) => ( { - id: `rate-${ i.label }`, - amount: i.amount, - displayName: i.label, - } ) ); - }; - - const shippingRates = getShippingRates(); - - // This is a bit of a hack, but we need some way to get the shipping information before rendering the button, and - // since we don't have any address information at this point it seems best to rely on what came with the cart response. - // Relying on what's provided in the cart response seems safest since it should always include a valid shipping - // rate if one is required and available. - // If no shipping rate is found we can't render the button so we just exit. - if ( options.requestShipping && ! shippingRates.length ) { - return; - } - - const elements = api.getStripe().elements( { - mode: options?.mode ?? 'payment', - amount: options?.total, - currency: options?.currency, + startExpressCheckoutElement: async ( options ) => { + const stripe = await api.getStripe(); + const elements = stripe.elements( { + mode: options.mode ?? 'payment', + amount: options.total, + currency: options.currency, paymentMethodCreation: 'manual', appearance: getExpressCheckoutButtonAppearance(), locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', @@ -305,15 +286,16 @@ jQuery( ( $ ) => { } // Add products to the cart if everything is right. + // TODO ~FR: use cartApi wcpayECE.addToCart(); } const clickOptions = { - lineItems: normalizeLineItems( options.displayItems ), + lineItems: options.displayItems, emailRequired: true, shippingAddressRequired: options.requestShipping, phoneNumberRequired: options.requestPhone, - shippingRates, + shippingRates: options.shippingRates, allowedShippingCountries: getExpressCheckoutData( 'checkout' ).allowed_shipping_countries, @@ -324,24 +306,21 @@ jQuery( ( $ ) => { } ); eceButton.on( 'shippingaddresschange', async ( event ) => - shippingAddressChangeHandler( api, event, elements ) + shippingAddressChangeHandler( event, elements ) ); eceButton.on( 'shippingratechange', async ( event ) => - shippingRateChangeHandler( api, event, elements ) + shippingRateChangeHandler( event, elements ) ); eceButton.on( 'confirm', async ( event ) => { - const order = options.order ?? 0; - return onConfirmHandler( api, - api.getStripe(), + stripe, elements, wcpayECE.completePayment, wcpayECE.abortPayment, - event, - order + event ); } ); @@ -413,6 +392,7 @@ jQuery( ( $ ) => { ...depositObject, }; + // TODO ~FR: replace with cartApi return api.expressCheckoutECEGetSelectedProductData( data ); }, @@ -439,6 +419,7 @@ jQuery( ( $ ) => { $.when( wcpayECE.getSelectedProductData() ) .then( ( response ) => { + // TODO ~FR: this seems new const isDeposits = wcpayECE.productHasDepositOption(); /** * If the customer aborted the express checkout, @@ -534,42 +515,9 @@ jQuery( ( $ ) => { /** * Initialize event handlers and UI state */ - init: () => { - if ( - getExpressCheckoutData( 'button_context' ) === 'pay_for_order' - ) { - if ( ! window.wcpayECEPayForOrderParams ) { - return; - } - - const { - total: { amount: total }, - displayItems, - order, - } = wcpayECEPayForOrderParams; - - if ( total === 0 ) { - expressCheckoutButtonUi.hideContainer(); - expressCheckoutButtonUi.getButtonSeparator().hide(); - return; - } - - wcpayECE.startExpressCheckoutElement( { - mode: 'payment', - total, - currency: getExpressCheckoutData( 'checkout' ) - ?.currency_code, - requestShipping: false, - requestPhone: - getExpressCheckoutData( 'checkout' ) - ?.needs_payer_phone ?? false, - displayItems, - order, - } ); - } else if ( - getExpressCheckoutData( 'button_context' ) === 'product' - ) { - wcpayECE.startExpressCheckoutElement( { + init: async () => { + if ( getExpressCheckoutData( 'button_context' ) === 'product' ) { + await wcpayECE.startExpressCheckoutElement( { mode: 'payment', total: getExpressCheckoutData( 'product' )?.total.amount, currency: getExpressCheckoutData( 'product' )?.currency, @@ -583,26 +531,36 @@ jQuery( ( $ ) => { .displayItems, } ); } else { - // If this is the cart or checkout page, we need to request the - // cart details. - api.expressCheckoutECEGetCartDetails().then( ( cart ) => { - if ( cart.total.amount === 0 ) { - expressCheckoutButtonUi.hideContainer(); - expressCheckoutButtonUi.getButtonSeparator().hide(); - } else { - wcpayECE.startExpressCheckoutElement( { - mode: 'payment', - total: cart.total.amount, - currency: getExpressCheckoutData( 'checkout' ) - ?.currency_code, - requestShipping: cart.needs_shipping, - requestPhone: - getExpressCheckoutData( 'checkout' ) - ?.needs_payer_phone ?? false, - displayItems: cart.displayItems, - } ); - } - } ); + // If this is the cart page, or checkout page, or pay-for-order page, we need to request the cart details. + const cartData = await getCartApiHandler().getCart(); + const total = transformPrice( + parseInt( cartData.totals.total_price, 10 ) - + parseInt( cartData.totals.total_refund || 0, 10 ), + cartData.totals + ); + if ( total === 0 ) { + expressCheckoutButtonUi.hideContainer(); + expressCheckoutButtonUi.getButtonSeparator().hide(); + } else { + await wcpayECE.startExpressCheckoutElement( { + mode: 'payment', + total, + currency: cartData.totals.currency_code.toLowerCase(), + // pay-for-order should never display the shipping selection. + requestShipping: + getExpressCheckoutData( 'button_context' ) !== + 'pay_for_order' && cartData.needs_shipping, + shippingRates: transformCartDataForShippingRates( + cartData + ), + requestPhone: + getExpressCheckoutData( 'checkout' ) + ?.needs_payer_phone ?? false, + displayItems: transformCartDataForDisplayItems( + cartData + ), + } ); + } } // After initializing a new express checkout button, we need to reset the paymentAborted flag. diff --git a/client/tokenized-payment-request/order-api.js b/client/tokenized-express-checkout/order-api.js similarity index 91% rename from client/tokenized-payment-request/order-api.js rename to client/tokenized-express-checkout/order-api.js index 13c580e752c..96b1896cc59 100644 --- a/client/tokenized-payment-request/order-api.js +++ b/client/tokenized-express-checkout/order-api.js @@ -3,9 +3,13 @@ */ import apiFetch from '@wordpress/api-fetch'; import { addQueryArgs } from '@wordpress/url'; -import { getPaymentRequestData } from './frontend-utils'; -export default class PaymentRequestOrderApi { +/** + * Internal dependencies + */ +import { getExpressCheckoutData } from './utils'; + +export default class ExpressCheckoutOrderApi { // parameters used in every request, just in different ways. orderId; key; @@ -44,7 +48,7 @@ export default class PaymentRequestOrderApi { method: 'POST', path: `/wc/store/v1/checkout/${ this.orderId }`, headers: { - Nonce: getPaymentRequestData( 'nonce' ).store_api_nonce, + Nonce: getExpressCheckoutData( 'nonce' ).store_api_nonce, }, data: { ...paymentData, diff --git a/client/tokenized-express-checkout/test/event-handlers.js b/client/tokenized-express-checkout/test/event-handlers.js deleted file mode 100644 index 8c3d8a1c28b..00000000000 --- a/client/tokenized-express-checkout/test/event-handlers.js +++ /dev/null @@ -1,571 +0,0 @@ -/** - * Internal dependencies - */ -import { - shippingAddressChangeHandler, - shippingRateChangeHandler, - onConfirmHandler, -} from '../event-handlers'; -import { - normalizeLineItems, - normalizeShippingAddress, - normalizeOrderData, - normalizePayForOrderData, -} from '../utils'; - -describe( 'Express checkout event handlers', () => { - describe( 'shippingAddressChangeHandler', () => { - let api; - let event; - let elements; - - beforeEach( () => { - api = { - expressCheckoutECECalculateShippingOptions: jest.fn(), - }; - event = { - address: { - recipient: 'John Doe', - addressLine: [ '123 Main St' ], - city: 'New York', - state: 'NY', - country: 'US', - postal_code: '10001', - }, - resolve: jest.fn(), - reject: jest.fn(), - }; - elements = { - update: jest.fn(), - }; - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - test( 'should handle successful response', async () => { - const response = { - result: 'success', - total: { amount: 1000 }, - shipping_options: [ - { id: 'option_1', label: 'Standard Shipping' }, - ], - displayItems: [ { label: 'Sample Item', amount: 500 } ], - }; - - api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( - response - ); - - await shippingAddressChangeHandler( api, event, elements ); - - const expectedNormalizedAddress = normalizeShippingAddress( - event.address - ); - expect( - api.expressCheckoutECECalculateShippingOptions - ).toHaveBeenCalledWith( expectedNormalizedAddress ); - - const expectedNormalizedLineItems = normalizeLineItems( - response.displayItems - ); - expect( elements.update ).toHaveBeenCalledWith( { amount: 1000 } ); - expect( event.resolve ).toHaveBeenCalledWith( { - shippingRates: response.shipping_options, - lineItems: expectedNormalizedLineItems, - } ); - expect( event.reject ).not.toHaveBeenCalled(); - } ); - - test( 'should handle unsuccessful response', async () => { - const response = { - result: 'error', - }; - - api.expressCheckoutECECalculateShippingOptions.mockResolvedValue( - response - ); - - await shippingAddressChangeHandler( api, event, elements ); - - const expectedNormalizedAddress = normalizeShippingAddress( - event.address - ); - expect( - api.expressCheckoutECECalculateShippingOptions - ).toHaveBeenCalledWith( expectedNormalizedAddress ); - expect( elements.update ).not.toHaveBeenCalled(); - expect( event.resolve ).not.toHaveBeenCalled(); - expect( event.reject ).toHaveBeenCalled(); - } ); - - test( 'should handle API call failure', async () => { - api.expressCheckoutECECalculateShippingOptions.mockRejectedValue( - new Error( 'API error' ) - ); - - await shippingAddressChangeHandler( api, event, elements ); - - const expectedNormalizedAddress = normalizeShippingAddress( - event.address - ); - expect( - api.expressCheckoutECECalculateShippingOptions - ).toHaveBeenCalledWith( expectedNormalizedAddress ); - expect( elements.update ).not.toHaveBeenCalled(); - expect( event.resolve ).not.toHaveBeenCalled(); - expect( event.reject ).toHaveBeenCalled(); - } ); - } ); - - describe( 'shippingRateChangeHandler', () => { - let api; - let event; - let elements; - - beforeEach( () => { - api = { - expressCheckoutECEUpdateShippingDetails: jest.fn(), - }; - event = { - shippingRate: { - id: 'rate_1', - label: 'Standard Shipping', - amount: 500, - }, - resolve: jest.fn(), - reject: jest.fn(), - }; - elements = { - update: jest.fn(), - }; - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - test( 'should handle successful response', async () => { - const response = { - result: 'success', - total: { amount: 1500 }, - displayItems: [ { label: 'Sample Item', amount: 1000 } ], - }; - - api.expressCheckoutECEUpdateShippingDetails.mockResolvedValue( - response - ); - - await shippingRateChangeHandler( api, event, elements ); - - const expectedNormalizedLineItems = normalizeLineItems( - response.displayItems - ); - expect( - api.expressCheckoutECEUpdateShippingDetails - ).toHaveBeenCalledWith( event.shippingRate ); - expect( elements.update ).toHaveBeenCalledWith( { amount: 1500 } ); - expect( event.resolve ).toHaveBeenCalledWith( { - lineItems: expectedNormalizedLineItems, - } ); - expect( event.reject ).not.toHaveBeenCalled(); - } ); - - test( 'should handle unsuccessful response', async () => { - const response = { - result: 'error', - }; - - api.expressCheckoutECEUpdateShippingDetails.mockResolvedValue( - response - ); - - await shippingRateChangeHandler( api, event, elements ); - - expect( - api.expressCheckoutECEUpdateShippingDetails - ).toHaveBeenCalledWith( event.shippingRate ); - expect( elements.update ).not.toHaveBeenCalled(); - expect( event.resolve ).not.toHaveBeenCalled(); - expect( event.reject ).toHaveBeenCalled(); - } ); - - test( 'should handle API call failure', async () => { - api.expressCheckoutECEUpdateShippingDetails.mockRejectedValue( - new Error( 'API error' ) - ); - - await shippingRateChangeHandler( api, event, elements ); - - expect( - api.expressCheckoutECEUpdateShippingDetails - ).toHaveBeenCalledWith( event.shippingRate ); - expect( elements.update ).not.toHaveBeenCalled(); - expect( event.resolve ).not.toHaveBeenCalled(); - expect( event.reject ).toHaveBeenCalled(); - } ); - } ); - - describe( 'onConfirmHandler', () => { - let api; - let stripe; - let elements; - let completePayment; - let abortPayment; - let event; - let order; - - beforeEach( () => { - api = { - expressCheckoutECECreateOrder: jest.fn(), - expressCheckoutECEPayForOrder: jest.fn(), - confirmIntent: jest.fn(), - }; - stripe = { - createPaymentMethod: jest.fn(), - }; - elements = { - submit: jest.fn(), - }; - completePayment = jest.fn(); - abortPayment = jest.fn(); - event = { - billingDetails: { - name: 'John Doe', - email: 'john.doe@example.com', - address: { - organization: 'Some Company', - country: 'US', - line1: '123 Main St', - line2: 'Apt 4B', - city: 'New York', - state: 'NY', - postal_code: '10001', - }, - phone: '(123) 456-7890', - }, - shippingAddress: { - name: 'John Doe', - organization: 'Some Company', - address: { - country: 'US', - line1: '123 Main St', - line2: 'Apt 4B', - city: 'New York', - state: 'NY', - postal_code: '10001', - }, - }, - shippingRate: { id: 'rate_1' }, - expressPaymentType: 'express', - }; - order = 123; - global.window.wcpayFraudPreventionToken = 'token123'; - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - test( 'should abort payment if elements.submit fails', async () => { - elements.submit.mockResolvedValue( { - error: { message: 'Submit error' }, - } ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - expect( elements.submit ).toHaveBeenCalled(); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Submit error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - - test( 'should abort payment if stripe.createPaymentMethod fails', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - error: { message: 'Payment method error' }, - } ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - expect( elements.submit ).toHaveBeenCalled(); - expect( stripe.createPaymentMethod ).toHaveBeenCalledWith( { - elements, - } ); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Payment method error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - - test( 'should abort payment if expressCheckoutECECreateOrder fails', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECECreateOrder.mockResolvedValue( { - result: 'error', - messages: 'Order creation error', - } ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - const expectedOrderData = normalizeOrderData( event, 'pm_123' ); - expect( api.expressCheckoutECECreateOrder ).toHaveBeenCalledWith( - expectedOrderData - ); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Order creation error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - - test( 'should complete payment if confirmationRequest is true', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECECreateOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockReturnValue( true ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( completePayment ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( abortPayment ).not.toHaveBeenCalled(); - } ); - - test( 'should complete payment if confirmationRequest returns a redirect URL', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECECreateOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockResolvedValue( - 'https://example.com/confirmation_redirect' - ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( completePayment ).toHaveBeenCalledWith( - 'https://example.com/confirmation_redirect' - ); - expect( abortPayment ).not.toHaveBeenCalled(); - } ); - - test( 'should abort payment if confirmIntent throws an error', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECECreateOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockRejectedValue( - new Error( 'Intent confirmation error' ) - ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Intent confirmation error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - - test( 'should abort payment if expressCheckoutECEPayForOrder fails', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECEPayForOrder.mockResolvedValue( { - result: 'error', - messages: 'Order creation error', - } ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event, - order - ); - - const expectedOrderData = normalizePayForOrderData( - event, - 'pm_123' - ); - expect( api.expressCheckoutECEPayForOrder ).toHaveBeenCalledWith( - 123, - expectedOrderData - ); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Order creation error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - - test( 'should complete payment (pay for order) if confirmationRequest is true', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECEPayForOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockReturnValue( true ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event, - order - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( completePayment ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( abortPayment ).not.toHaveBeenCalled(); - } ); - - test( 'should complete payment (pay for order) if confirmationRequest returns a redirect URL', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECEPayForOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockResolvedValue( - 'https://example.com/confirmation_redirect' - ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event, - order - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( completePayment ).toHaveBeenCalledWith( - 'https://example.com/confirmation_redirect' - ); - expect( abortPayment ).not.toHaveBeenCalled(); - } ); - - test( 'should abort payment (pay for order) if confirmIntent throws an error', async () => { - elements.submit.mockResolvedValue( {} ); - stripe.createPaymentMethod.mockResolvedValue( { - paymentMethod: { id: 'pm_123' }, - } ); - api.expressCheckoutECEPayForOrder.mockResolvedValue( { - result: 'success', - redirect: 'https://example.com/redirect', - } ); - api.confirmIntent.mockRejectedValue( - new Error( 'Intent confirmation error' ) - ); - - await onConfirmHandler( - api, - stripe, - elements, - completePayment, - abortPayment, - event, - order - ); - - expect( api.confirmIntent ).toHaveBeenCalledWith( - 'https://example.com/redirect' - ); - expect( abortPayment ).toHaveBeenCalledWith( - event, - 'Intent confirmation error' - ); - expect( completePayment ).not.toHaveBeenCalled(); - } ); - } ); -} ); diff --git a/client/tokenized-payment-request/transformers/__tests__/wc-to-stripe.test.js b/client/tokenized-express-checkout/transformers/__tests__/wc-to-stripe.test.js similarity index 91% rename from client/tokenized-payment-request/transformers/__tests__/wc-to-stripe.test.js rename to client/tokenized-express-checkout/transformers/__tests__/wc-to-stripe.test.js index 1ab4a2f8cf9..13e8048d423 100644 --- a/client/tokenized-payment-request/transformers/__tests__/wc-to-stripe.test.js +++ b/client/tokenized-express-checkout/transformers/__tests__/wc-to-stripe.test.js @@ -3,12 +3,12 @@ */ import { transformPrice, - transformCartDataForShippingOptions, + transformCartDataForShippingRates, transformCartDataForDisplayItems, } from '../wc-to-stripe'; -global.wcpayPaymentRequestParams = {}; -global.wcpayPaymentRequestParams.checkout = {}; +global.wcpayExpressCheckoutParams = {}; +global.wcpayExpressCheckoutParams.checkout = {}; describe( 'wc-to-stripe transformers', () => { describe( 'transformCartDataForDisplayItems', () => { @@ -139,8 +139,8 @@ describe( 'wc-to-stripe transformers', () => { totals: {}, } ) ).toStrictEqual( [ - { amount: 4500, label: 'Physical subscription' }, - { amount: 150, label: 'WC Bookings – Equipment Rental' }, + { amount: 4500, name: 'Physical subscription' }, + { amount: 150, name: 'WC Bookings – Equipment Rental' }, ] ); } ); @@ -176,7 +176,7 @@ describe( 'wc-to-stripe transformers', () => { currency_suffix: '', }, } ) - ).toStrictEqual( [ { amount: 545, label: 'Tax' } ] ); + ).toStrictEqual( [ { amount: 545, name: 'Tax' } ] ); } ); it( 'transforms the tax amount when not present', () => { @@ -211,7 +211,7 @@ describe( 'wc-to-stripe transformers', () => { describe( 'transformPrice', () => { afterEach( () => { - delete global.wcpayPaymentRequestParams.checkout.currency_decimals; + delete global.wcpayExpressCheckoutParams.checkout.currency_decimals; } ); it( 'transforms the price', () => { @@ -237,7 +237,7 @@ describe( 'wc-to-stripe transformers', () => { } ); it( 'transforms the price if the currency is a zero decimal currency (e.g.: Yen)', () => { - global.wcpayPaymentRequestParams.checkout.currency_decimals = 0; + global.wcpayExpressCheckoutParams.checkout.currency_decimals = 0; // with zero decimals, `18` would mean `18`. expect( transformPrice( 18, { currency_minor_unit: 0 } ) ).toBe( 18 @@ -245,7 +245,7 @@ describe( 'wc-to-stripe transformers', () => { } ); it( 'transforms the price if the currency a zero decimal currency (e.g.: Yen) but it is configured with one decimal', () => { - global.wcpayPaymentRequestParams.checkout.currency_decimals = 0; + global.wcpayExpressCheckoutParams.checkout.currency_decimals = 0; // with zero decimals, `18` would mean `18`. // But since Stripe expects the price to be in the minimum currency amount, the return value should be `18` expect( transformPrice( 180, { currency_minor_unit: 1 } ) ).toBe( @@ -254,10 +254,10 @@ describe( 'wc-to-stripe transformers', () => { } ); } ); - describe( 'transformCartDataForShippingOptions', () => { - it( 'transforms shipping rates', () => { + describe( 'transformCartDataForShippingRates', () => { + it( 'transforms shipping rates, placing the selected one at the top of the list', () => { expect( - transformCartDataForShippingOptions( { + transformCartDataForShippingRates( { shipping_rates: [ { package_id: 0, @@ -286,7 +286,7 @@ describe( 'wc-to-stripe transformers', () => { value: 'Beanie × 1', }, ], - selected: true, + selected: false, currency_code: 'USD', currency_symbol: '$', currency_minor_unit: 2, @@ -310,7 +310,7 @@ describe( 'wc-to-stripe transformers', () => { value: 'Beanie × 1', }, ], - selected: false, + selected: true, currency_code: 'USD', currency_symbol: '$', currency_minor_unit: 2, @@ -348,30 +348,30 @@ describe( 'wc-to-stripe transformers', () => { ], } ) ).toEqual( [ - { - amount: 1000, - detail: '', - id: 'flat_rate:14', - label: 'CA Flat rate', - }, { amount: 350, - detail: '', + deliveryEstimate: '', id: 'local_pickup:15', - label: 'Local pickup', + displayName: 'Local pickup', + }, + { + amount: 1000, + deliveryEstimate: '', + id: 'flat_rate:14', + displayName: 'CA Flat rate', }, { amount: 0, - detail: '', + deliveryEstimate: '', id: 'free_shipping:13', - label: 'Free shipping', + displayName: 'Free shipping', }, ] ); } ); it( 'transforms shipping options for local pickup', () => { expect( - transformCartDataForShippingOptions( { + transformCartDataForShippingRates( { shipping_rates: [ { package_id: 0, @@ -430,10 +430,10 @@ describe( 'wc-to-stripe transformers', () => { ).toEqual( [ { amount: 0, - detail: + deliveryEstimate: '42 Wallaby Way, Sydney New South Wales 200, Australia - Ask for P. Sherman', id: 'pickup_location:1', - label: + displayName: 'Local pickup – options coming from WooCommerce Blocks (Australian warehouse)', }, ] ); diff --git a/client/tokenized-express-checkout/transformers/stripe-to-wc.js b/client/tokenized-express-checkout/transformers/stripe-to-wc.js new file mode 100644 index 00000000000..82534e259b8 --- /dev/null +++ b/client/tokenized-express-checkout/transformers/stripe-to-wc.js @@ -0,0 +1,98 @@ +/** + * Transform shipping address information from Stripe's address object to + * the cart shipping address object shape. + * + * @param {string} name Stripe's shipping address item + * @param {Object} shippingAddress Stripe's shipping address item + * + * @return {Object} The shipping address in the shape expected by the cart. + */ +export const transformStripeShippingAddressForStoreApi = ( + name, + shippingAddress +) => ( { + first_name: name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '', + company: shippingAddress.organization ?? '', + address_1: shippingAddress.line1 ?? '', + address_2: shippingAddress.line2 ?? '', + city: shippingAddress.city ?? '', + state: shippingAddress.state ?? '', + postcode: shippingAddress.postal_code?.replace( ' ', '' ) ?? '', + country: shippingAddress.country ?? '', +} ); + +/** + * Transform order data from Stripe's object to the expected format for WC. + * + * @param {Object} paymentData Stripe's order object. + * @param {string} paymentMethodId Stripe's payment method id. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const transformStripePaymentMethodForStoreApi = ( + paymentData, + paymentMethodId +) => { + const name = paymentData.billingDetails?.name || ''; + const billing = paymentData.billingDetails?.address ?? {}; + + const billingPhone = + paymentData.billingDetails?.phone?.replace( /[() -]/g, '' ) ?? + paymentData.payerPhone?.replace( /[() -]/g, '' ) ?? + ''; + + 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.billingDetails?.email ?? '', + phone: billingPhone, + }, + // refreshing any shipping address data, now that the customer is placing the order. + // in the case of pay-for-order, the shipping address property might not be present. + shipping_address: paymentData.shippingAddress + ? { + ...transformStripeShippingAddressForStoreApi( + paymentData.shippingAddress.name || '', + paymentData.shippingAddress.address + ), + // adding the phone number, because it might be needed. + // Stripe doesn't provide us with a different phone number for shipping, + // so we're going to use the same phone used for billing. + phone: billingPhone, + } + : undefined, + payment_method: 'woocommerce_payments', + payment_data: [ + { + key: 'payment_method', + value: 'card', + }, + { + key: 'payment_request_type', + value: paymentData.expressPaymentType, + }, + { + key: 'wcpay-fraud-prevention-token', + value: window.wcpayFraudPreventionToken ?? '', + }, + { + key: 'wcpay-payment-method', + value: paymentMethodId, + }, + { + key: 'express_payment_type', + value: paymentData.expressPaymentType, + }, + ], + }; +}; diff --git a/client/tokenized-payment-request/transformers/wc-to-stripe.js b/client/tokenized-express-checkout/transformers/wc-to-stripe.js similarity index 60% rename from client/tokenized-payment-request/transformers/wc-to-stripe.js rename to client/tokenized-express-checkout/transformers/wc-to-stripe.js index e6dc43b0362..867a389006b 100644 --- a/client/tokenized-payment-request/transformers/wc-to-stripe.js +++ b/client/tokenized-express-checkout/transformers/wc-to-stripe.js @@ -7,7 +7,7 @@ import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ -import { getPaymentRequestData } from '../frontend-utils'; +import { getExpressCheckoutData } from '../utils'; /** * GooglePay/ApplePay expect the prices to be formatted in cents. @@ -22,18 +22,20 @@ import { getPaymentRequestData } from '../frontend-utils'; */ export const transformPrice = ( price, priceObject ) => { const currencyDecimals = - getPaymentRequestData( 'checkout' )?.currency_decimals ?? 2; + getExpressCheckoutData( 'checkout' )?.currency_decimals ?? 2; // making sure the decimals are always correctly represented for GooglePay/ApplePay, since they don't allow us to specify the decimals. return price * 10 ** ( currencyDecimals - priceObject.currency_minor_unit ); }; /** - * 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 + * Transforms the data from the Store API Cart response to `displayItems` for the Stripe ECE. + * See for the data structure: + * - https://docs.stripe.com/js/elements_object/express_checkout_element_shippingaddresschange_event + * - https://docs.stripe.com/js/elements_object/express_checkout_element_shippingratechange_event * * @param {Object} cartData Store API Cart response object. - * @return {{pending: boolean, label: string, amount: integer}} `displayItems` for Stripe. + * @return {{pending: boolean, name: string, amount: integer}} `displayItems` for Stripe. */ export const transformCartDataForDisplayItems = ( cartData ) => { const displayItems = cartData.items.map( ( item ) => ( { @@ -41,7 +43,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { parseInt( item.prices.price, 10 ), item.prices ), - label: [ + name: [ item.name, item.quantity > 1 && `(x${ item.quantity })`, item.variation && @@ -61,7 +63,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { if ( taxAmount ) { displayItems.push( { amount: transformPrice( taxAmount, cartData.totals ), - label: __( 'Tax', 'woocommerce-payments' ), + name: __( 'Tax', 'woocommerce-payments' ), } ); } @@ -72,7 +74,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { if ( shippingAmount ) { displayItems.push( { amount: transformPrice( shippingAmount, cartData.totals ), - label: __( 'Shipping', 'woocommerce-payments' ), + name: __( 'Shipping', 'woocommerce-payments' ), } ); } @@ -80,7 +82,7 @@ export const transformCartDataForDisplayItems = ( cartData ) => { if ( refundAmount ) { displayItems.push( { amount: -transformPrice( refundAmount, cartData.totals ), - label: __( 'Refund', 'woocommerce-payments' ), + name: __( 'Refund', 'woocommerce-payments' ), } ); } @@ -88,25 +90,33 @@ export const transformCartDataForDisplayItems = ( cartData ) => { }; /** - * Transforms the data from the Store API Cart response to `shippingOptions` for the Stripe PRB. + * Transforms the data from the Store API Cart response to `shippingRates` for the Stripe ECE. * * @param {Object} cartData Store API Cart response object. - * @return {{id: string, label: string, amount: integer, detail: string}} `shippingOptions` for Stripe. + * @return {{id: string, label: string, amount: integer, deliveryEstimate: string}} `shippingRates` for Stripe. */ -export const transformCartDataForShippingOptions = ( cartData ) => - cartData.shipping_rates[ 0 ].shipping_rates.map( ( rate ) => ( { - id: rate.rate_id, - label: decodeEntities( rate.name ), - amount: transformPrice( parseInt( rate.price, 10 ), rate ), - detail: [ - rate.meta_data.find( - ( metadata ) => metadata.key === 'pickup_address' - )?.value, - rate.meta_data.find( - ( metadata ) => metadata.key === 'pickup_details' - )?.value, - ] - .filter( Boolean ) - .map( decodeEntities ) - .join( ' - ' ), - } ) ); +export const transformCartDataForShippingRates = ( cartData ) => + cartData.shipping_rates?.[ 0 ].shipping_rates + .sort( ( rateA, rateB ) => { + if ( rateA.selected === rateB.selected ) { + return 0; // Keep relative order if both have the same value for 'selected' + } + + return rateA.selected ? -1 : 1; // Objects with 'selected: true' come first + } ) + .map( ( rate ) => ( { + id: rate.rate_id, + displayName: decodeEntities( rate.name ), + amount: transformPrice( parseInt( rate.price, 10 ), rate ), + deliveryEstimate: [ + rate.meta_data.find( + ( metadata ) => metadata.key === 'pickup_address' + )?.value, + rate.meta_data.find( + ( metadata ) => metadata.key === 'pickup_details' + )?.value, + ] + .filter( Boolean ) + .map( decodeEntities ) + .join( ' - ' ), + } ) ); diff --git a/client/tokenized-payment-request/payment-request.js b/client/tokenized-payment-request/payment-request.js index a2fcea161ac..046769cfd7d 100644 --- a/client/tokenized-payment-request/payment-request.js +++ b/client/tokenized-payment-request/payment-request.js @@ -89,8 +89,9 @@ export default class WooPaymentsPaymentRequest { async startPaymentRequest() { // reference to this class' instance, to be used inside callbacks to avoid `this` misunderstandings. const _self = this; + const stripe = await this.wcpayApi.getStripe(); const paymentRequest = getPaymentRequest( { - stripe: this.wcpayApi.getStripe(), + stripe, cartData: this.cachedCartData, productData: this.initialProductData, } ); @@ -124,8 +125,7 @@ export default class WooPaymentsPaymentRequest { this.paymentRequestCartApi.useSeparateCart(); } - const paymentRequestButton = this.wcpayApi - .getStripe() + const paymentRequestButton = stripe .elements() .create( 'paymentRequestButton', { paymentRequest: paymentRequest, diff --git a/client/types/deposits.d.ts b/client/types/deposits.d.ts index ce749a58c30..29ebb263977 100644 --- a/client/types/deposits.d.ts +++ b/client/types/deposits.d.ts @@ -2,7 +2,14 @@ import { TableCardColumn } from '@woocommerce/components'; export interface DepositsTableHeader extends TableCardColumn { - key: 'details' | 'date' | 'type' | 'amount' | 'status' | 'bankAccount'; + key: + | 'details' + | 'date' + | 'type' + | 'amount' + | 'status' + | 'bankAccount' + | 'bankReferenceKey'; cellClassName?: string; } @@ -24,6 +31,7 @@ export interface CachedDeposit { status: DepositStatus; bankAccount: string; automatic: boolean; + bank_reference_key: string; } export interface DepositsSummaryCache { diff --git a/composer.json b/composer.json index cdb679b4afb..f5b03aaa04f 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ "woocommerce/qit-cli": "0.4.0", "slevomat/coding-standard": "8.15.0", "dg/bypass-finals": "1.5.1", - "sirbrillig/phpcs-variable-analysis": "^2.11" + "sirbrillig/phpcs-variable-analysis": "^2.11", + "phpcompatibility/php-compatibility": "dev-develop as 9.3.5" }, "scripts": { "test": [ diff --git a/composer.lock b/composer.lock index bc02d67bfbf..f6276dc29e7 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": "595a21dda8d9c0943a32df05723b0e28", + "content-hash": "2f2c365c1ebb8b6af6e0df8c0ba64709", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -2737,33 +2737,45 @@ }, { "name": "phpcompatibility/php-compatibility", - "version": "9.3.5", + "version": "dev-develop", "source": { "type": "git", "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", - "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + "reference": "3f79f96be9289a49eb85a4db3e8f5b7d55ba06d2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", - "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/3f79f96be9289a49eb85a4db3e8f5b7d55ba06d2", + "reference": "3f79f96be9289a49eb85a4db3e8f5b7d55ba06d2", "shasum": "" }, "require": { - "php": ">=5.3", - "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.0.12", + "squizlabs/php_codesniffer": "^3.10.0" }, - "conflict": { - "squizlabs/php_codesniffer": "2.6.2" + "replace": { + "wimg/php-compatibility": "*" }, "require-dev": { - "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.1.3", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4 || ^10.1.0", + "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0" }, "suggest": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." }, + "default-branch": true, "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-master": "9.x-dev", + "dev-develop": "10.x-dev" + } + }, "notification-url": "https://packagist.org/downloads/", "license": [ "LGPL-3.0-or-later" @@ -2789,13 +2801,29 @@ "keywords": [ "compatibility", "phpcs", - "standards" + "standards", + "static analysis" ], "support": { "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibility/security/policy", "source": "https://github.com/PHPCompatibility/PHPCompatibility" }, - "time": "2019-12-27T09:44:58+00:00" + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + } + ], + "time": "2024-10-13T19:18:49+00:00" }, { "name": "phpcompatibility/phpcompatibility-paragonie", @@ -6961,10 +6989,18 @@ "time": "2020-01-04T15:36:55+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "phpcompatibility/php-compatibility", + "version": "dev-develop", + "alias": "9.3.5", + "alias_normalized": "9.3.5.0" + } + ], "minimum-stability": "dev", "stability-flags": { - "kalessil/production-dependencies-guard": 20 + "kalessil/production-dependencies-guard": 20, + "phpcompatibility/php-compatibility": 20 }, "prefer-stable": true, "prefer-lowest": false, @@ -6972,7 +7008,7 @@ "php": ">=7.3", "ext-json": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "7.3" }, diff --git a/includes/admin/class-wc-rest-payments-customer-controller.php b/includes/admin/class-wc-rest-payments-customer-controller.php index c0f43a97003..93a310ef868 100644 --- a/includes/admin/class-wc-rest-payments-customer-controller.php +++ b/includes/admin/class-wc-rest-payments-customer-controller.php @@ -99,7 +99,7 @@ public function get_customer_payment_methods( $request ) { * @param array|mixed $item Item to prepare. * @param WP_REST_Request $request Request instance. * - * @return WP_REST_Response|WP_Error|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function prepare_item_for_response( $item, $request ) { diff --git a/includes/admin/class-wc-rest-payments-onboarding-controller.php b/includes/admin/class-wc-rest-payments-onboarding-controller.php index 7a75d3d8de1..3903dad3ff7 100644 --- a/includes/admin/class-wc-rest-payments-onboarding-controller.php +++ b/includes/admin/class-wc-rest-payments-onboarding-controller.php @@ -219,7 +219,7 @@ public function get_embedded_kyc_session( WP_REST_Request $request ) { * * @param WP_REST_Request $request Request object. * - * @return WP_Error|WP_HTTP_Response|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function finalize_embedded_kyc( WP_REST_Request $request ) { $source = $request->get_param( 'source' ) ?? ''; diff --git a/includes/admin/class-wc-rest-payments-payment-intents-create-controller.php b/includes/admin/class-wc-rest-payments-payment-intents-create-controller.php index 972717724a8..39363cdfc00 100644 --- a/includes/admin/class-wc-rest-payments-payment-intents-create-controller.php +++ b/includes/admin/class-wc-rest-payments-payment-intents-create-controller.php @@ -318,7 +318,7 @@ public function get_item_schema() { * @param array|mixed $item Item to prepare. * @param WP_REST_Request $request Request instance. * - * @return WP_REST_Response|WP_Error|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function prepare_item_for_response( $item, $request ) { $prepared_item = []; diff --git a/includes/admin/class-wc-rest-payments-reader-controller.php b/includes/admin/class-wc-rest-payments-reader-controller.php index fa6fa5192e0..33c8eaec2dc 100644 --- a/includes/admin/class-wc-rest-payments-reader-controller.php +++ b/includes/admin/class-wc-rest-payments-reader-controller.php @@ -146,7 +146,7 @@ public function register_routes() { * * @param WP_REST_Request $request Full data about the request. * - * @return WP_Error|WP_HTTP_Response|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function get_summary( $request ) { diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 700d9b12754..e68cc4469d7 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -572,58 +572,6 @@ public function init_hooks() { $this->maybe_init_subscriptions_hooks(); } - /** - * Returns the gateway title - * - * @return string - * */ - public function get_title() { - $title = parent::get_title(); - - /** - * Allows themes or other plugins to override whether the "rich" payment method label - * (normally displayed on shortcode checkout/my account/pay-for-order pages) will be used. - * - * @since 8.6.0 - * - * @param $use_plain_method_label boolean Whether the "plain" payment method label will be displayed. - */ - if ( apply_filters( 'wcpay_checkout_use_plain_method_label', false ) ) { - return $title; - } - - if ( - ( is_checkout() || is_add_payment_method_page() ) && - ! isset( $_GET['change_payment_method'] ) // phpcs:ignore WordPress.Security.NonceVerification - ) { - $test_mode_badge = ''; - if ( WC_Payments::mode()->is_test() ) { - $test_mode_badge = '' . __( 'Test Mode', 'woocommerce-payments' ) . ''; - } - - $bnpl_messaging_container = ''; - if ( $this->payment_method->is_bnpl() ) { - $bnpl_messaging_container = ''; - } - - // the "plain" payment method label is displayed on some sections of the app - // - like "pay for order" when a payment method is pre-selected or a payment has previously failed. - $html = '' . $title . ''; - $html .= '
'; - $html .= '
'; - $html .= ' ' . $title . ''; - $html .= $test_mode_badge; - $html .= '
'; - $html .= $this->get_icon(); - $html .= $bnpl_messaging_container; - $html .= '
'; - - return $html; - } - - return $title; - } - /** * Updates icon and title using the account country. * This method runs on init is not in the controller because get_account_country might @@ -3261,15 +3209,6 @@ public function update_fraud_rules_based_on_general_options() { } } - /** - * Overriding the base method because the `alt` tag would otherwise output the markup returned by the `get_title()` method in this class - which we don't want. - * - * @return string - */ - public function get_icon() { - return '' . esc_attr( $this->payment_method->get_title() ) . ' payment method logo'; - } - /** * The URL for the current payment method's icon. * diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index d2a01ec48ff..e884d582ac8 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -1143,12 +1143,12 @@ public function maybe_handle_onboarding() { * * 0. Make changes to the account data if needed (e.g. reset account, disable test mode onboarding) * as instructed by the GET params. - * 0.1 If we reset the account -> redirect to CONNECT PAGE + * 0.1 If we reset the account -> redirect to CONNECT PAGE / SETTINGS PAGE If redirect to settings page flag set * 1. Returning from the WPCOM/Jetpack connection screen. * 1.1 SUCCESSFUL connection * 1.1.1 NO Stripe account connected -> redirect to ONBOARDING WIZARD * 1.1.2 Stripe account connected -> redirect to OVERVIEW PAGE - * 1.2 UNSUCCESSFUL connection -> redirect to CONNECT PAGE with ERROR message + * 1.2 UNSUCCESSFUL connection -> redirect to CONNECT PAGE with ERROR message / SETTINGS PAGE if redirect to settings page flag set * 2. Working WPCOM/Jetpack connection and fully onboarded Stripe account -> redirect to OVERVIEW PAGE * 3. Specific `from` places -> redirect to CONNECT PAGE regardless of the account status * 4. NO [working] WPCOM/Jetpack connection: @@ -1167,6 +1167,7 @@ public function maybe_handle_onboarding() { * 5.1.3 All other cases -> redirect to ONBOARDING WIZARD * 5.2 If PARTIALLY onboarded Stripe account connected -> redirect to STRIPE KYC * 5.3 If fully onboarded Stripe account connected -> redirect to OVERVIEW PAGE + * 5.3.1 If redirect to settings page flags set -> redirect to SETTINGS PAGE * * This logic is so complex because we use connect links as a catch-all place to * handle everything and anything related to the WooPayments account setup. It reduces the complexity on the @@ -1182,6 +1183,7 @@ public function maybe_handle_onboarding() { $progressive = ! empty( $_GET['progressive'] ) && 'true' === $_GET['progressive']; $collect_payout_requirements = ! empty( $_GET['collect_payout_requirements'] ) && 'true' === $_GET['collect_payout_requirements']; $create_test_drive_account = ! empty( $_GET['test_drive'] ) && 'true' === $_GET['test_drive']; + $redirect_to_settings_page = ! empty( $_GET['redirect_to_settings_page'] ) && 'true' === $_GET['redirect_to_settings_page']; // There is no point in auto starting test drive onboarding if we are not in the test drive mode. $auto_start_test_drive_onboarding = $create_test_drive_account && ! empty( $_GET['auto_start_test_drive_onboarding'] ) && @@ -1253,7 +1255,16 @@ public function maybe_handle_onboarding() { $this->cleanup_on_account_reset(); - // When we reset the account we want to always go the Connect page. Redirect immediately! + // When we reset the account and want to go back to the settings page - redirect immediately! + if ( $redirect_to_settings_page ) { + $this->redirect_service->redirect_to_settings_page( + WC_Payments_Onboarding_Service::FROM_RESET_ACCOUNT, + [ 'source' => $onboarding_source ] + ); + return; + } + + // Otherwise, when we reset the account we want to always go the Connect page. Redirect immediately! $this->redirect_service->redirect_to_connect_page( null, WC_Payments_Onboarding_Service::FROM_RESET_ACCOUNT, @@ -1312,6 +1323,16 @@ public function maybe_handle_onboarding() { array_merge( $tracks_props, [ 'mode' => WC_Payments_Onboarding_Service::is_test_mode_enabled() ? 'test' : 'live' ] ) ); + if ( $redirect_to_settings_page ) { + $this->redirect_service->redirect_to_settings_page( + WC_Payments_Onboarding_Service::FROM_WPCOM_CONNECTION, + [ + 'source' => $onboarding_source, + 'wcpay-connect-jetpack-error' => '1', + ] + ); + } + $this->redirect_service->redirect_to_connect_page( sprintf( /* translators: %s: WooPayments */ @@ -1343,15 +1364,23 @@ public function maybe_handle_onboarding() { && $this->has_working_jetpack_connection() && $this->is_stripe_account_valid() ) { + $params = [ + 'source' => $onboarding_source, + // Carry over some parameters as they may be used by our frontend logic. + 'wcpay-connection-success' => ! empty( $_GET['wcpay-connection-success'] ) ? '1' : false, + 'wcpay-sandbox-success' => ! empty( $_GET['wcpay-sandbox-success'] ) ? 'true' : false, + 'test_drive_error' => ! empty( $_GET['test_drive_error'] ) ? 'true' : false, + ]; + if ( $redirect_to_settings_page ) { + $this->redirect_service->redirect_to_settings_page( + $from, + $params + ); + return; + } $this->redirect_service->redirect_to_overview_page( $from, - [ - 'source' => $onboarding_source, - // Carry over some parameters as they may be used by our frontend logic. - 'wcpay-connection-success' => ! empty( $_GET['wcpay-connection-success'] ) ? '1' : false, - 'wcpay-sandbox-success' => ! empty( $_GET['wcpay-sandbox-success'] ) ? 'true' : false, - 'test_drive_error' => ! empty( $_GET['test_drive_error'] ) ? 'true' : false, - ] + $params ); return; } @@ -1361,13 +1390,14 @@ public function maybe_handle_onboarding() { in_array( $from, [ - WC_Payments_Onboarding_Service::FROM_WCADMIN_PAYMENTS_SETTINGS, WC_Payments_Onboarding_Service::FROM_STRIPE, ], true ) // This is a weird case, but it is best to handle it. || ( WC_Payments_Onboarding_Service::FROM_ONBOARDING_WIZARD === $from && ! $this->has_working_jetpack_connection() ) + // Redirect merchants coming from settings page to the connect page only if $redirect_to_settings_page is false. + || ( WC_Payments_Onboarding_Service::FROM_WCADMIN_PAYMENTS_SETTINGS === $from && ! $redirect_to_settings_page ) ) { $this->redirect_service->redirect_to_connect_page( ! empty( $_GET['wcpay-connection-error'] ) ? sprintf( @@ -1410,7 +1440,7 @@ public function maybe_handle_onboarding() { 'auto_start_test_drive_onboarding' => $auto_start_test_drive_onboarding ? 'true' : false, 'from' => WC_Payments_Onboarding_Service::FROM_WPCOM_CONNECTION, 'source' => $onboarding_source, - + 'redirect_to_settings_page' => $redirect_to_settings_page ? 'true' : false, ], self::get_connect_url( $wcpay_connect_param ) // Instruct Jetpack to return here (connect link). ), @@ -1480,6 +1510,7 @@ public function maybe_handle_onboarding() { 'auto_start_test_drive_onboarding' => 'true', // This is critical. 'test_mode' => $should_onboard_in_test_mode ? 'true' : false, 'source' => $onboarding_source, + 'redirect_to_settings_page' => $redirect_to_settings_page ? 'true' : false, ] ); return; diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index 246dbb13e34..195dbe115a7 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -1657,12 +1657,13 @@ private function generate_dispute_created_note( $charge_id, $amount, $reason, $d /* translators: %1: the disputed amount and currency; %2: the dispute reason; %3 the deadline date for responding to dispute */ __( 'Payment has been disputed for %1$s with reason "%2$s". Response due by %3$s.', 'woocommerce-payments' ), [ - 'a' => '', + 'a' => '', ] ), $amount, $reason, - $due_by + $due_by, + $dispute_url ); } @@ -1681,10 +1682,11 @@ private function generate_dispute_closed_note( $charge_id, $status ) { /* translators: %1: the dispute status */ __( 'Payment dispute has been closed with status %1$s. See dispute overview for more details.', 'woocommerce-payments' ), [ - 'a' => '', + 'a' => '', ] ), - $status + $status, + $dispute_url ); } @@ -1747,7 +1749,7 @@ private function compose_dispute_url( $charge_id ) { return add_query_arg( [ 'page' => 'wc-admin', - 'path' => '/payments/transactions/details', + 'path' => rawurlencode( '/payments/transactions/details' ), 'id' => $charge_id, ], admin_url( 'admin.php' ) diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index c9dcdbc1ee3..82b33593008 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -101,175 +101,6 @@ 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 ); - - add_action( - 'woocommerce_store_api_checkout_update_order_from_request', - [ - $this, - 'tokenized_cart_set_payment_method_type', - ], - 10, - 2 - ); - add_filter( 'rest_pre_dispatch', [ $this, 'tokenized_cart_store_api_address_normalization' ], 10, 3 ); - } - - /** - * Updates the checkout order based on the request, to set the Apple Pay/Google Pay payment method title. - * - * @param \WC_Order $order The order to be updated. - * @param \WP_REST_Request $request Store API request to update the order. - */ - public function tokenized_cart_set_payment_method_type( \WC_Order $order, \WP_REST_Request $request ) { - if ( ! isset( $request['payment_method'] ) || 'woocommerce_payments' !== $request['payment_method'] ) { - return; - } - - if ( empty( $request['payment_data'] ) ) { - return; - } - - $payment_data = []; - foreach ( $request['payment_data'] as $data ) { - $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); - } - - if ( empty( $payment_data['payment_request_type'] ) ) { - return; - } - - $payment_request_type = wc_clean( wp_unslash( $payment_data['payment_request_type'] ) ); - - $payment_method_titles = [ - 'apple_pay' => 'Apple Pay', - 'google_pay' => 'Google Pay', - ]; - - $suffix = apply_filters( 'wcpay_payment_request_payment_method_title_suffix', 'WooPayments' ); - if ( ! empty( $suffix ) ) { - $suffix = " ($suffix)"; - } - - $payment_method_title = isset( $payment_method_titles[ $payment_request_type ] ) ? $payment_method_titles[ $payment_request_type ] : 'Payment Request'; - $order->set_payment_method_title( $payment_method_title . $suffix ); - } - - /** - * Google Pay/Apple Pay parameters for address data might need some massaging for some of the countries. - * Ensuring that the Store API doesn't throw a `rest_invalid_param` error message for some of those scenarios. - * - * @param mixed $response Response to replace the requested version with. - * @param \WP_REST_Server $server Server instance. - * @param \WP_REST_Request $request Request used to generate the response. - * - * @return mixed - */ - public function tokenized_cart_store_api_address_normalization( $response, $server, $request ) { - if ( 'true' !== $request->get_header( 'X-WooPayments-Tokenized-Cart' ) ) { - return $response; - } - - // header added as additional layer of security. - $nonce = $request->get_header( 'X-WooPayments-Tokenized-Cart-Nonce' ); - if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_nonce' ) ) { - return $response; - } - - // This route is used to get shipping rates. - // GooglePay/ApplePay might provide us with "trimmed" zip codes. - // If that's the case, let's temporarily allow to skip the zip code validation, in order to get some shipping rates. - $is_update_customer_route = $request->get_route() === '/wc/store/v1/cart/update-customer'; - if ( $is_update_customer_route ) { - add_filter( 'woocommerce_validate_postcode', [ $this, 'maybe_skip_postcode_validation' ], 10, 3 ); - } - - $request_data = $request->get_json_params(); - if ( isset( $request_data['shipping_address'] ) ) { - $request->set_param( 'shipping_address', $this->transform_prb_address_state_data( $request_data['shipping_address'] ) ); - // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. - // we need to modify the zip code to ensure that shipping zone identification still works. - if ( $is_update_customer_route ) { - $request->set_param( 'shipping_address', $this->transform_prb_address_postcode_data( $request_data['shipping_address'] ) ); - } - } - if ( isset( $request_data['billing_address'] ) ) { - $request->set_param( 'billing_address', $this->transform_prb_address_state_data( $request_data['billing_address'] ) ); - // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. - // we need to modify the zip code to ensure that shipping zone identification still works. - if ( $is_update_customer_route ) { - $request->set_param( 'billing_address', $this->transform_prb_address_postcode_data( $request_data['billing_address'] ) ); - } - } - - 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 state address data fields into values that are valid for WooCommerce. - * - * @param array $address The address to normalize from the GooglePay/ApplePay request. - * - * @return array - */ - private function transform_prb_address_state_data( $address ) { - $country = $address['country'] ?? ''; - if ( empty( $country ) ) { - return $address; - } - - // States from Apple Pay or Google Pay are in long format, we need their short format.. - $state = $address['state'] ?? ''; - if ( ! empty( $state ) ) { - $address['state'] = $this->get_normalized_state( $state, $country ); - } - - return $address; - } - - /** - * Transform a GooglePay/ApplePay postcode address data fields into values that are valid for WooCommerce. - * - * @param array $address The address to normalize from the GooglePay/ApplePay request. - * - * @return array - */ - private function transform_prb_address_postcode_data( $address ) { - $country = $address['country'] ?? ''; - if ( empty( $country ) ) { - return $address; - } - - // Normalizes postal code in case of redacted data from Apple Pay or Google Pay. - $postcode = $address['postcode'] ?? ''; - if ( ! empty( $postcode ) ) { - $address['postcode'] = $this->get_normalized_postal_code( $postcode, $country ); - } - - return $address; } /** @@ -967,115 +798,6 @@ public function filter_cart_needs_shipping_address( $needs_shipping_address ) { return $needs_shipping_address; } - /** - * Checks if given state is normalized. - * - * @param string $state State. - * @param string $country Two-letter country code. - * - * @return bool Whether state is normalized or not. - */ - public function is_normalized_state( $state, $country ) { - $wc_states = WC()->countries->get_states( $country ); - - return is_array( $wc_states ) && array_key_exists( $state, $wc_states ); - } - - /** - * Sanitize string for comparison. - * - * @param string $string String to be sanitized. - * - * @return string The sanitized string. - */ - public function sanitize_string( $string ) { - return trim( wc_strtolower( remove_accents( $string ) ) ); - } - - /** - * Get normalized state from Payment Request API dropdown list of states. - * - * @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. - */ - public function get_normalized_state_from_pr_states( $state, $country ) { - // Include Payment Request API State list for compatibility with WC countries/states. - include_once WCPAY_ABSPATH . 'includes/constants/class-payment-request-button-states.php'; - $pr_states = \WCPay\Constants\Payment_Request_Button_States::STATES; - - if ( ! isset( $pr_states[ $country ] ) ) { - return $state; - } - - foreach ( $pr_states[ $country ] as $wc_state_abbr => $pr_state ) { - $sanitized_state_string = $this->sanitize_string( $state ); - // Checks if input state matches with Payment Request state code (0), name (1) or localName (2). - if ( - ( ! empty( $pr_state[0] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[0] ) ) || - ( ! empty( $pr_state[1] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[1] ) ) || - ( ! empty( $pr_state[2] ) && $sanitized_state_string === $this->sanitize_string( $pr_state[2] ) ) - ) { - return $wc_state_abbr; - } - } - - return $state; - } - - /** - * Get normalized state from WooCommerce list of translated states. - * - * @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. - */ - public function get_normalized_state_from_wc_states( $state, $country ) { - $wc_states = WC()->countries->get_states( $country ); - - if ( is_array( $wc_states ) ) { - foreach ( $wc_states as $wc_state_abbr => $wc_state_value ) { - if ( preg_match( '/' . preg_quote( $wc_state_value, '/' ) . '/i', $state ) ) { - return $wc_state_abbr; - } - } - } - - return $state; - } - - /** - * Gets the normalized state/county field because in some - * cases, the state/county field is formatted differently from - * 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 $country Two-letter country code. - * - * @return string Normalized state abbreviation. - */ - public function get_normalized_state( $state, $country ) { - // If it's empty or already normalized, skip. - if ( ! $state || $this->is_normalized_state( $state, $country ) ) { - return $state; - } - - // Try to match state from the Payment Request API list of states. - $state = $this->get_normalized_state_from_pr_states( $state, $country ); - - // If it's normalized, return. - if ( $this->is_normalized_state( $state, $country ) ) { - return $state; - } - - // If the above doesn't work, fallback to matching against the list of translated - // states from WooCommerce. - return $this->get_normalized_state_from_wc_states( $state, $country ); - } - /** * Calculates whether Apple Pay is enabled for this store. * The option value is not stored in the database, and is calculated diff --git a/includes/class-wc-payments-redirect-service.php b/includes/class-wc-payments-redirect-service.php index 7863cf0aa6d..bae6299664e 100644 --- a/includes/class-wc-payments-redirect-service.php +++ b/includes/class-wc-payments-redirect-service.php @@ -182,6 +182,34 @@ public function redirect_to_onboarding_wizard( ?string $from = null, array $addi $this->redirect_to( admin_url( add_query_arg( $params, 'admin.php' ) ) ); } + /** + * Immediately redirect to the settings page. + * + * Note that this function immediately ends the execution. + * + * @param string|null $from Optional. Source of the redirect. + * @param array $additional_params Optional. Additional URL params to add to the redirect URL. + */ + public function redirect_to_settings_page( ?string $from = null, array $additional_params = [] ): void { + $params = [ + 'page' => 'wc-settings', + 'tab' => 'checkout', + ]; + + if ( count( $params ) === count( array_intersect_assoc( $_GET, $params ) ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended + // We are already in the settings page. Do nothing. + return; + } + + $params = array_merge( $params, $additional_params ); + + if ( ! empty( $from ) ) { + $params['from'] = $from; + } + + $this->redirect_to( admin_url( add_query_arg( $params, 'admin.php' ) ) ); + } + /** * Redirect to the overview page. * diff --git a/includes/class-wc-payments-token-service.php b/includes/class-wc-payments-token-service.php index 166ef6c9685..7bfdc482e18 100644 --- a/includes/class-wc-payments-token-service.php +++ b/includes/class-wc-payments-token-service.php @@ -62,7 +62,7 @@ public function __construct( WC_Payments_API_Client $payments_api_client, WC_Pay * * @param array $payment_method Payment method to be added. * @param WP_User $user User to attach payment method to. - * @return WC_Payment_Token|WC_Payment_Token_CC|WC_Payment_Token_WCPay_SEPA The WC object for the payment token. + * @return WC_Payment_Token The WC object for the payment token. */ public function add_token_to_user( $payment_method, $user ) { // Clear cached payment methods. diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index 09ae6646dfc..0cad2ffe950 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -440,7 +440,8 @@ private function process_webhook_payment_intent_failed( $event_body ) { $intent_id = $this->read_webhook_property( $event_object, 'id' ); $intent_status = $this->read_webhook_property( $event_object, 'status' ); - $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); } + $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); + } /** * Process webhook for a successful payment intent. @@ -714,7 +715,7 @@ private function has_webhook_property( $array, $key ) { * @throws Invalid_Webhook_Data_Exception Required parameters not found. * @throws Invalid_Payment_Method_Exception When unable to resolve intent ID to order. * - * @return boolean|WC_Order|WC_Order_Refund + * @return null|WC_Order */ private function get_order_from_event_body( $event_body ) { $event_data = $this->read_webhook_property( $event_body, 'data' ); @@ -724,7 +725,7 @@ private function get_order_from_event_body( $event_body ) { // Look up the order related to this intent. $order = $this->wcpay_db->order_from_intent_id( $intent_id ); - if ( ! $order ) { + if ( ! $order instanceof \WC_Order ) { // Retrieving order with order_id in case intent_id was not properly set. Logger::debug( 'intent_id not found, using order_id to retrieve order' ); $metadata = $this->read_webhook_property( $event_object, 'metadata' ); @@ -740,11 +741,11 @@ private function get_order_from_event_body( $event_body ) { $order = $this->wcpay_db->order_from_order_id( $order_id ); } elseif ( ! empty( $event_object['invoice'] ) ) { // If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event. - return false; + return null; } } - if ( ! $order ) { + if ( ! $order instanceof \WC_Order ) { throw new Invalid_Payment_Method_Exception( sprintf( /* translators: %1: intent ID */ diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 1e1e167aa84..66e72bb8dbf 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -1880,10 +1880,11 @@ public static function load_stripe_bnpl_site_messaging() { $are_subscriptions_enabled = class_exists( 'WC_Subscriptions' ) || class_exists( 'WC_Subscriptions_Core_Plugin' ); if ( $are_subscriptions_enabled ) { global $product; - $is_subscription = $product && WC_Subscriptions_Product::is_subscription( $product ); + $is_subscription = $product && WC_Subscriptions_Product::is_subscription( $product ); + $cart_contains_subscription = is_cart() && WC_Subscriptions_Cart::cart_contains_subscription(); } - if ( ! $is_subscription ) { + if ( ! $is_subscription && ! $cart_contains_subscription ) { require_once __DIR__ . '/class-wc-payments-payment-method-messaging-element.php'; $stripe_site_messaging = new WC_Payments_Payment_Method_Messaging_Element( self::$account, self::$card_gateway ); echo wp_kses( $stripe_site_messaging->init() ?? '', 'post' ); diff --git a/includes/class-woopay-tracker.php b/includes/class-woopay-tracker.php index 78e7ea5c8da..538ec873dc8 100644 --- a/includes/class-woopay-tracker.php +++ b/includes/class-woopay-tracker.php @@ -294,6 +294,8 @@ public function tracks_record_event( $event_name, $properties = [], $is_admin_ev return false; } + $properties = apply_filters( 'wcpay_tracks_event_properties', $properties, $event_name ); + if ( isset( $properties['record_event_data'] ) ) { if ( isset( $properties['record_event_data']['is_admin_event'] ) ) { $is_admin_event = $properties['record_event_data']['is_admin_event']; diff --git a/includes/constants/class-payment-request-button-states.php b/includes/constants/class-express-checkout-element-states.php similarity index 99% rename from includes/constants/class-payment-request-button-states.php rename to includes/constants/class-express-checkout-element-states.php index 63160ce6b56..d2e02532e22 100644 --- a/includes/constants/class-payment-request-button-states.php +++ b/includes/constants/class-express-checkout-element-states.php @@ -1,6 +1,6 @@ 'Apple Pay', + 'google_pay' => 'Google Pay', + ]; + + $suffix = apply_filters( 'wcpay_payment_request_payment_method_title_suffix', 'WooPayments' ); + if ( ! empty( $suffix ) ) { + $suffix = " ($suffix)"; + } + + $payment_method_title = isset( $payment_method_titles[ $payment_request_type ] ) ? $payment_method_titles[ $payment_request_type ] : 'Payment Request'; + $order->set_payment_method_title( $payment_method_title . $suffix ); + } + + /** + * Google Pay/Apple Pay parameters for address data might need some massaging for some of the countries. + * Ensuring that the Store API doesn't throw a `rest_invalid_param` error message for some of those scenarios. + * + * @param mixed $response Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed + */ + public function tokenized_cart_store_api_address_normalization( $response, $server, $request ) { + if ( 'true' !== $request->get_header( 'X-WooPayments-Tokenized-Cart' ) ) { + return $response; + } + + // header added as additional layer of security. + $nonce = $request->get_header( 'X-WooPayments-Tokenized-Cart-Nonce' ); + if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_nonce' ) ) { + return $response; + } + + // This route is used to get shipping rates. + // GooglePay/ApplePay might provide us with "trimmed" zip codes. + // If that's the case, let's temporarily allow to skip the zip code validation, in order to get some shipping rates. + $is_update_customer_route = $request->get_route() === '/wc/store/v1/cart/update-customer'; + if ( $is_update_customer_route ) { + add_filter( 'woocommerce_validate_postcode', [ $this, 'maybe_skip_postcode_validation' ], 10, 3 ); + } + + $request_data = $request->get_json_params(); + if ( isset( $request_data['shipping_address'] ) ) { + $request->set_param( 'shipping_address', $this->transform_ece_address_state_data( $request_data['shipping_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'shipping_address', $this->transform_ece_address_postcode_data( $request_data['shipping_address'] ) ); + } + } + if ( isset( $request_data['billing_address'] ) ) { + $request->set_param( 'billing_address', $this->transform_ece_address_state_data( $request_data['billing_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'billing_address', $this->transform_ece_address_postcode_data( $request_data['billing_address'] ) ); + } + } + + return $response; + } + + /** + * 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 state address data fields into values that are valid for WooCommerce. + * + * @param array $address The address to normalize from the GooglePay/ApplePay request. + * + * @return array + */ + private function transform_ece_address_state_data( $address ) { + $country = $address['country'] ?? ''; + if ( empty( $country ) ) { + return $address; + } + + // States from Apple Pay or Google Pay are in long format, we need their short format.. + $state = $address['state'] ?? ''; + if ( ! empty( $state ) ) { + $address['state'] = $this->express_checkout_button_helper->get_normalized_state( $state, $country ); + } + + return $address; + } + + /** + * Transform a GooglePay/ApplePay postcode address data fields into values that are valid for WooCommerce. + * + * @param array $address The address to normalize from the GooglePay/ApplePay request. + * + * @return array + */ + private function transform_ece_address_postcode_data( $address ) { + $country = $address['country'] ?? ''; + if ( empty( $country ) ) { + return $address; + } + + // Normalizes postal code in case of redacted data from Apple Pay or Google Pay. + $postcode = $address['postcode'] ?? ''; + if ( ! empty( $postcode ) ) { + $address['postcode'] = $this->express_checkout_button_helper->get_normalized_postal_code( $postcode, $country ); + } + + return $address; + } } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php index 7d9fece536d..1f9470bbf65 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-display-handler.php @@ -93,6 +93,8 @@ public function init() { add_action( 'woocommerce_pay_order_before_payment', [ $this, 'display_express_checkout_buttons' ], 1 ); } + add_filter( 'wcpay_tracks_event_properties', [ $this, 'record_all_ece_tracks_events' ], 10, 2 ); + if ( $this->is_pay_for_order_flow_supported() ) { add_action( 'wp_enqueue_scripts', [ $this, 'add_pay_for_order_params_to_js_config' ], 5 ); } @@ -218,4 +220,27 @@ function ( $js_config ) use ( $order ) { } // phpcs:enable WordPress.Security.NonceVerification } + + /** + * Record all ECE tracks events by adding the track_on_all_stores flag to the event. + * + * @param array $properties Event properties. + * @param string $event_name Event name. + * @return array + */ + public function record_all_ece_tracks_events( $properties, $event_name ) { + $tracked_events_prefixes = [ + 'wcpay_applepay', + 'wcpay_gpay', + ]; + + foreach ( $tracked_events_prefixes as $prefix ) { + if ( strpos( $event_name, $prefix ) === 0 ) { + $properties['record_event_data']['track_on_all_stores'] = true; + break; + } + } + + return $properties; + } } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php index d15595e98ad..03f0100cefa 100644 --- a/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php +++ b/includes/express-checkout/class-wc-payments-express-checkout-button-handler.php @@ -109,15 +109,15 @@ public function init() { * @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 = [ + $button_type = $this->gateway->get_option( 'payment_request_button_type' ); + $common_settings = $this->express_checkout_helper->get_common_button_settings(); + $express_checkout_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 ); + return array_merge( $common_settings, $express_checkout_button_settings ); } /** @@ -222,7 +222,7 @@ public function scripts() { return; } - $payment_request_params = [ + $express_checkout_params = [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'wc_ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), 'stripe' => [ @@ -231,18 +231,23 @@ public function scripts() { 'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ), ], 'nonce' => [ - 'get_cart_details' => wp_create_nonce( 'wcpay-get-cart-details' ), - 'shipping' => wp_create_nonce( 'wcpay-payment-request-shipping' ), - 'update_shipping' => wp_create_nonce( 'wcpay-update-shipping-method' ), - 'checkout' => wp_create_nonce( 'woocommerce-process_checkout' ), - 'add_to_cart' => wp_create_nonce( 'wcpay-add-to-cart' ), - 'empty_cart' => wp_create_nonce( 'wcpay-empty-cart' ), - 'get_selected_product_data' => wp_create_nonce( 'wcpay-get-selected-product-data' ), - 'platform_tracker' => wp_create_nonce( 'platform_tracks_nonce' ), - 'pay_for_order' => wp_create_nonce( 'pay_for_order' ), + 'get_cart_details' => wp_create_nonce( 'wcpay-get-cart-details' ), + 'shipping' => wp_create_nonce( 'wcpay-payment-request-shipping' ), + 'update_shipping' => wp_create_nonce( 'wcpay-update-shipping-method' ), + 'checkout' => wp_create_nonce( 'woocommerce-process_checkout' ), + 'add_to_cart' => wp_create_nonce( 'wcpay-add-to-cart' ), + 'empty_cart' => wp_create_nonce( 'wcpay-empty-cart' ), + 'get_selected_product_data' => wp_create_nonce( 'wcpay-get-selected-product-data' ), + 'platform_tracker' => wp_create_nonce( 'platform_tracks_nonce' ), + 'pay_for_order' => wp_create_nonce( 'pay_for_order' ), + // needed to communicate via the Store API. + 'tokenized_cart_nonce' => wp_create_nonce( 'woopayments_tokenized_cart_nonce' ), + 'tokenized_cart_session_nonce' => wp_create_nonce( 'woopayments_tokenized_cart_session_nonce' ), + 'store_api_nonce' => wp_create_nonce( 'wc_store_api' ), ], 'checkout' => [ 'currency_code' => strtolower( get_woocommerce_currency() ), + 'currency_decimals' => WC_Payments::get_localization_service()->get_currency_format( get_woocommerce_currency() )['num_decimals'], '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. @@ -291,7 +296,7 @@ public function scripts() { ); } - wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $payment_request_params ); + wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $express_checkout_params ); wp_set_script_translations( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'woocommerce-payments' ); 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 76ad18c7ec4..561679882a7 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 @@ -427,7 +427,7 @@ public function should_show_express_checkout_button() { ) // ...and billing is calculated based on billing address. - && 'billing' === get_option( 'woocommerce_tax_based_on' ) + && wc_tax_enabled() && 'billing' === get_option( 'woocommerce_tax_based_on' ) ) { return false; } @@ -889,7 +889,7 @@ public function get_normalized_state( $state, $country ) { } // Try to match state from the Payment Request API list of states. - $state = $this->get_normalized_state_from_pr_states( $state, $country ); + $state = $this->get_normalized_state_from_ece_states( $state, $country ); // If it's normalized, return. if ( $this->is_normalized_state( $state, $country ) ) { @@ -976,10 +976,10 @@ public function is_normalized_state( $state, $country ) { * * @return string Normalized state or original state input value. */ - public function get_normalized_state_from_pr_states( $state, $country ) { - // Include Payment Request API State list for compatibility with WC countries/states. - include_once WCPAY_ABSPATH . 'includes/constants/class-payment-request-button-states.php'; - $pr_states = \WCPay\Constants\Payment_Request_Button_States::STATES; + public function get_normalized_state_from_ece_states( $state, $country ) { + // Include Express Checkout Element API State list for compatibility with WC countries/states. + include_once WCPAY_ABSPATH . 'includes/constants/class-express-checkout-element-states.php'; + $pr_states = \WCPay\Constants\Express_Checkout_Element_States::STATES; if ( ! isset( $pr_states[ $country ] ) ) { return $state; diff --git a/includes/reports/class-wc-rest-payments-reports-authorizations-controller.php b/includes/reports/class-wc-rest-payments-reports-authorizations-controller.php index 0834d078eeb..3f1e60f7e8b 100644 --- a/includes/reports/class-wc-rest-payments-reports-authorizations-controller.php +++ b/includes/reports/class-wc-rest-payments-reports-authorizations-controller.php @@ -127,7 +127,7 @@ public function get_authorization( $request ) { * @param array|mixed $item Item to prepare. * @param WP_REST_Request $request Request instance. * - * @return WP_REST_Response|WP_Error|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function prepare_item_for_response( $item, $request ) { diff --git a/includes/reports/class-wc-rest-payments-reports-transactions-controller.php b/includes/reports/class-wc-rest-payments-reports-transactions-controller.php index 1e38d6cd746..a307c12c627 100644 --- a/includes/reports/class-wc-rest-payments-reports-transactions-controller.php +++ b/includes/reports/class-wc-rest-payments-reports-transactions-controller.php @@ -115,7 +115,7 @@ public function get_transaction( $request ) { * @param array|mixed $item Item to prepare. * @param WP_REST_Request $request Request instance. * - * @return WP_REST_Response|WP_Error|WP_REST_Response + * @return WP_REST_Response|WP_Error */ public function prepare_item_for_response( $item, $request ) { diff --git a/includes/woopay/class-woopay-session.php b/includes/woopay/class-woopay-session.php index f19549cd9fa..dce1878265e 100644 --- a/includes/woopay/class-woopay-session.php +++ b/includes/woopay/class-woopay-session.php @@ -45,6 +45,7 @@ public static function init() { add_filter( 'woocommerce_session_handler', [ __CLASS__, 'add_woopay_store_api_session_handler' ], 20 ); add_action( 'woocommerce_order_payment_status_changed', [ __CLASS__, 'woopay_order_payment_status_changed' ] ); add_action( 'woopay_restore_order_customer_id', [ __CLASS__, 'restore_order_customer_id_from_requests_with_verified_email' ] ); + add_filter( 'woocommerce_order_needs_payment', [ __CLASS__, 'woopay_trial_subscriptions_handler' ], 20, 3 ); register_deactivation_hook( WCPAY_PLUGIN_FILE, [ __CLASS__, 'run_and_remove_woopay_restore_order_customer_id_schedules' ] ); @@ -272,6 +273,33 @@ public static function automatewoo_refer_a_friend_referral_from_parameter( $advo return $automatewoo_referral; } + /** + * Process trial subscriptions for WooPay. + * + * @param bool $needs_payment If the order needs payment. + * @param \WC_Order $order The order. + * @param array $valid_order_statuses The valid order statuses. + */ + public static function woopay_trial_subscriptions_handler( $needs_payment, $order, $valid_order_statuses ) { + if ( ! self::is_request_from_woopay() || ! \WC_Payments_Utils::is_store_api_request() ) { + return $needs_payment; + } + + if ( ! self::is_woopay_enabled() ) { + return $needs_payment; + } + + if ( ! class_exists( 'WC_Subscriptions_Cart' ) || $order->get_total() > 0 ) { + return $needs_payment; + } + + if ( \WC_Subscriptions_Cart::cart_contains_subscription() ) { + return true; + } + + return $needs_payment; + } + /** * Returns the payload from a cart token. * diff --git a/package-lock.json b/package-lock.json index 866b3bae7b8..83cd7a25f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "8.5.0", + "version": "8.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "8.5.0", + "version": "8.5.1", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index ba36d21ebc4..23844afd780 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "8.5.0", + "version": "8.5.1", "main": "webpack.config.js", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -62,6 +62,7 @@ "lint:css": "stylelint 'client/**/*.scss' 'client/**/*.css' 'assets/**/*.scss' 'assets/**/*.css'", "lint:js": "tsc --noEmit && eslint . --ext=js,jsx,ts,tsx", "lint:php": "./vendor/bin/phpcs --standard=phpcs.xml.dist $(git ls-files | grep .php$)", + "lint:php-compatibility": "./vendor/bin/phpcs --standard=phpcs-compat.xml.dist $(git ls-files | grep .php$)", "lint:php-fix": "./vendor/bin/phpcbf --standard=phpcs.xml.dist $(git ls-files | grep .php$)", "format": "npm run format:js && npm run format:css", "format:js": "npm run format:provided '**/*.js' '**/*.ts' '**/*.tsx'", diff --git a/phpcs-compat.xml.dist b/phpcs-compat.xml.dist index 996f8374292..83faef2a44a 100644 --- a/phpcs-compat.xml.dist +++ b/phpcs-compat.xml.dist @@ -12,6 +12,7 @@ ./docker/* ./node_modules/* ./vendor/* + ./vendor-dist/* tests/ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index e997f0fa2fd..c3547cfc17b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -19,7 +19,7 @@ - + diff --git a/readme.txt b/readme.txt index 5c885d4c88b..bbe3e783642 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: woocommerce payments, apple pay, credit card, google pay, payment, payment Requires at least: 6.0 Tested up to: 6.7 Requires PHP: 7.3 -Stable tag: 8.5.0 +Stable tag: 8.5.1 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -94,6 +94,10 @@ Please note that our support for the checkout block is still experimental and th == Changelog == += 8.5.1 - 2024-11-25 = +* Fix - fix: remove "test mode" badge from shortcode checkout. + + = 8.5.0 - 2024-11-13 = * Add - Add country-specific test card numbers for credit card testing * Add - Add risk level information to the fraud and risk box on the order details page. diff --git a/tests/e2e-pw/specs/shopper/klarna-checkout-purchase.spec.ts b/tests/e2e-pw/specs/shopper/klarna-checkout-purchase.spec.ts index 1ce6d5917a0..b810f32ecb0 100644 --- a/tests/e2e-pw/specs/shopper/klarna-checkout-purchase.spec.ts +++ b/tests/e2e-pw/specs/shopper/klarna-checkout-purchase.spec.ts @@ -68,7 +68,6 @@ test.describe( 'Klarna Checkout', () => { await shopperPage .locator( '.wc_payment_methods' ) .getByText( 'Klarna' ) - .nth( 1 ) .click(); await shopper.placeOrder( shopperPage ); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 35b5e5c5e5f..38ca21dbdbd 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -24,18 +24,18 @@ DEBUG=false ---
-Choose WCPay Server instance +Choose Transact Platform Server instance

-It is possible to use the live server or a local docker instance of WCPay server locally. On Github Actions, live server is used for tests. Add the following env variables to your `local.env` based on your preference (replace values as required). +It is possible to use the live server or a local docker instance of Transact Platform Server locally. On Github Actions, live server is used for tests. Add the following env variables to your `local.env` based on your preference (replace values as required). **Using Local Server on Docker** -By default, the local E2E environment is configured to use WCPay local server instance. Add the following env variables to configure the local server instance. +By default, the local E2E environment is configured to use Transact Platform local server instance. Add the following env variables to configure the local server instance. ``` -# WooCommerce Payments Server Repo -WCP_SERVER_REPO='https://github.com/server-repo.git or git@github.com:org/server-repo.git' +# Transact Platform Server Repo +TRANSACT_PLATFORM_SERVER_REPO='https://github.com/server-repo.git or git@github.com:org/server-repo.git' # Stripe account data. Need to support level 3 data to run tests successfully. # These values can be obtained from the Stripe Dashboard: https://dashboard.stripe.com/test/apikeys @@ -135,7 +135,7 @@ E2E_WC_VERSION='' - WC E2E Client: http://localhost:8084 - WC E2E Server: http://localhost:8088 (Available only when using local server) - **Note:** Be aware that the server port may change in the `docker-compose.e2e.yml` configuration, so when you can't access the server, try running `docker port woocommerce_payments_server_wordpress_e2e 80` to find out the bound port of the E2E server container. + **Note:** Be aware that the server port may change in the `docker-compose.e2e.yml` configuration, so when you can't access the server, try running `docker port transact_platform_server_wordpress_e2e 80` to find out the bound port of the E2E server container.

diff --git a/tests/e2e/env/down.sh b/tests/e2e/env/down.sh index 6dc112bef7f..ba3cc6f2503 100755 --- a/tests/e2e/env/down.sh +++ b/tests/e2e/env/down.sh @@ -13,7 +13,7 @@ docker compose -f $E2E_ROOT/env/docker-compose.yml down if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then step "Stopping server containers" - docker compose -f $E2E_ROOT/deps/wcp-server/docker-compose.yml down + docker compose -f $E2E_ROOT/deps/transact-platform-server/docker-compose.yml down fi # Remove auth credentials from the Playwright config. diff --git a/tests/e2e/env/setup.sh b/tests/e2e/env/setup.sh index 50d4313f235..d2aa3a50e89 100755 --- a/tests/e2e/env/setup.sh +++ b/tests/e2e/env/setup.sh @@ -33,19 +33,19 @@ if [[ $FORCE_E2E_DEPS_SETUP ]]; then sudo rm -rf tests/e2e/deps fi -# Setup WCPay local server instance. +# Setup Transact Platform local server instance. # Only if E2E_USE_LOCAL_SERVER is present & equals to true. if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then if [[ ! -d "$SERVER_PATH" ]]; then - step "Fetching server (branch ${WCP_SERVER_BRANCH-trunk})" + step "Fetching server (branch ${TRANSACT_PLATFORM_SERVER_BRANCH-trunk})" - if [[ -z $WCP_SERVER_REPO ]]; then - echo "WCP_SERVER_REPO env variable is not defined" + if [[ -z $TRANSACT_PLATFORM_SERVER_REPO ]]; then + echo "TRANSACT_PLATFORM_SERVER_REPO env variable is not defined" exit 1; fi rm -rf "$SERVER_PATH" - git clone --depth=1 --branch "${WCP_SERVER_BRANCH-trunk}" "$WCP_SERVER_REPO" "$SERVER_PATH" + git clone --depth=1 --branch "${TRANSACT_PLATFORM_SERVER_BRANCH-trunk}" "$TRANSACT_PLATFORM_SERVER_REPO" "$SERVER_PATH" else echo "Using cached server at ${SERVER_PATH}" fi diff --git a/tests/e2e/env/shared.sh b/tests/e2e/env/shared.sh index 042eb3d615a..39ac4fdd760 100644 --- a/tests/e2e/env/shared.sh +++ b/tests/e2e/env/shared.sh @@ -5,8 +5,8 @@ cwd=$(pwd) export WCP_ROOT=$cwd export E2E_ROOT="$cwd/tests/e2e" export WP_URL="localhost:8084" -export SERVER_PATH="$E2E_ROOT/deps/wcp-server" -export SERVER_CONTAINER="woocommerce_payments_server_wordpress_e2e" +export SERVER_PATH="$E2E_ROOT/deps/transact-platform-server" +export SERVER_CONTAINER="transact_platform_server_wordpress_e2e" export DEV_TOOLS_DIR="wcp-dev-tools" export DEV_TOOLS_PATH="$E2E_ROOT/deps/$DEV_TOOLS_DIR" export CLIENT_CONTAINER="wcp_e2e_wordpress" diff --git a/tests/e2e/env/up.sh b/tests/e2e/env/up.sh index 1a5998b9047..80696a67579 100755 --- a/tests/e2e/env/up.sh +++ b/tests/e2e/env/up.sh @@ -13,5 +13,5 @@ docker compose -f "$E2E_ROOT/env/docker-compose.yml" up -d if [[ "$E2E_USE_LOCAL_SERVER" != false ]]; then step "Starting server containers" - docker compose -f "$E2E_ROOT/deps/wcp-server/docker-compose.yml" up -d + docker compose -f "$E2E_ROOT/deps/transact-platform-server/docker-compose.yml" up -d fi diff --git a/tests/js/jest.config.js b/tests/js/jest.config.js index 7a918a8d63c..b81f434b8c5 100644 --- a/tests/js/jest.config.js +++ b/tests/js/jest.config.js @@ -45,6 +45,8 @@ module.exports = { '/.*/build-module/', '/docker/', '/tests/e2e', + // We'll delete the directory and its contents as part of https://github.com/Automattic/woocommerce-payments/issues/9722 . + '/client/tokenized-payment-request', ], transform: { ...tsjPreset.transform, diff --git a/tests/unit/express-checkout/test-class-wc-payments-express-checkout-ajax-handler.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-ajax-handler.php new file mode 100644 index 00000000000..de7d43f2bb3 --- /dev/null +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-ajax-handler.php @@ -0,0 +1,190 @@ +getMockBuilder( WC_Payments_Express_Checkout_Button_Helper::class ) + ->onlyMethods( [] ) + ->disableOriginalConstructor() + ->getMock(); + + update_option( '_wcpay_feature_tokenized_cart_ece', '1' ); + $this->ajax_handler = new WC_Payments_Express_Checkout_Ajax_Handler( + $express_checkout_button_helper_mock + ); + $this->ajax_handler->init(); + } + + /** + * Clean up after each test. + * + * @return void + */ + public function tear_down() { + delete_option( '_wcpay_feature_tokenized_cart_ece' ); + + parent::tear_down(); + } + + public function test_tokenized_cart_address_avoid_normalization_when_missing_header() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Tokenized-Cart', null ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + + $this->ajax_handler->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + + $this->assertSame( 'California', $shipping_address['state'] ); + } + + public function test_tokenized_cart_address_avoid_normalization_when_wrong_nonce() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', 'invalid-nonce' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + + $this->ajax_handler->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + + $this->assertSame( 'California', $shipping_address['state'] ); + } + + public function test_tokenized_cart_address_state_normalization() { + $request = new WP_REST_Request(); + $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'US', + 'state' => 'California', + ] + ); + $request->set_param( + 'billing_address', + [ + 'country' => 'CA', + 'state' => 'Colombie-Britannique', + ] + ); + + $this->ajax_handler->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + $billing_address = $request->get_param( 'billing_address' ); + + $this->assertSame( 'CA', $shipping_address['state'] ); + $this->assertSame( 'BC', $billing_address['state'] ); + } + + public function test_tokenized_cart_address_postcode_normalization() { + $request = new WP_REST_Request(); + $request->set_route( '/wc/store/v1/cart/update-customer' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'CA', + 'postcode' => 'H3B', + ] + ); + $request->set_param( + 'billing_address', + [ + 'country' => 'US', + 'postcode' => '90210', + ] + ); + + $this->ajax_handler->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + $billing_address = $request->get_param( 'billing_address' ); + + // this should be modified. + $this->assertSame( 'H3B000', $shipping_address['postcode'] ); + // this shouldn't be modified. + $this->assertSame( '90210', $billing_address['postcode'] ); + } + + public function test_tokenized_cart_avoid_address_postcode_normalization_if_route_incorrect() { + $request = new WP_REST_Request(); + $request->set_route( '/wc/store/v1/checkout' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); + $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_param( + 'shipping_address', + [ + 'country' => 'CA', + 'postcode' => 'H3B', + 'state' => 'Colombie-Britannique', + ] + ); + $request->set_param( + 'billing_address', + [ + 'country' => 'CA', + 'postcode' => 'H3B', + 'state' => 'Colombie-Britannique', + ] + ); + + $this->ajax_handler->tokenized_cart_store_api_address_normalization( null, null, $request ); + + $shipping_address = $request->get_param( 'shipping_address' ); + $billing_address = $request->get_param( 'billing_address' ); + + // this should be modified. + $this->assertSame( 'BC', $shipping_address['state'] ); + $this->assertSame( 'BC', $billing_address['state'] ); + // this shouldn't be modified. + $this->assertSame( 'H3B', $shipping_address['postcode'] ); + $this->assertSame( 'H3B', $billing_address['postcode'] ); + } +} diff --git a/tests/unit/test-class-wc-payments-account.php b/tests/unit/test-class-wc-payments-account.php index 54ae7dede7e..f0087e3e966 100644 --- a/tests/unit/test-class-wc-payments-account.php +++ b/tests/unit/test-class-wc-payments-account.php @@ -257,6 +257,7 @@ public function test_maybe_handle_onboarding_connect_from_known_from( $has_working_jetpack_connection, $is_stripe_connected, $create_test_drive_account, + $redirect_to_settings_page, $expected_next_step ) { @@ -278,9 +279,10 @@ public function test_maybe_handle_onboarding_connect_from_known_from( $_GET['page'] = 'wc-admin'; $_GET['path'] = '/payments/some-bogus-page'; - $_GET['from'] = $onboarding_from; - $_GET['source'] = $onboarding_source; - $_GET['test_drive'] = $create_test_drive_account ? 'true' : null; + $_GET['from'] = $onboarding_from; + $_GET['source'] = $onboarding_source; + $_GET['test_drive'] = $create_test_drive_account ? 'true' : null; + $_GET['redirect_to_settings_page'] = $redirect_to_settings_page ? 'true' : null; $this->mock_jetpack_connection( $has_working_jetpack_connection ); @@ -398,6 +400,18 @@ public function test_maybe_handle_onboarding_connect_from_known_from( ) ); break; + case 'settings_page': + $this->mock_api_client + ->expects( $this->never() ) + ->method( 'start_server_connection' ); + $mock_redirect_service + ->expects( $this->once() ) + ->method( 'redirect_to' ) + ->with( + // It should redirect to settings page URL. + $this->stringContains( 'page=wc-settings&tab=checkout' ), + ); + break; default: $this->fail( 'Unexpected redirect type: ' . $expected_next_step ); break; @@ -431,6 +445,7 @@ public function provider_onboarding_known_froms() { false, true, false, + false, 'start_jetpack_connection', ], 'From Woo Payments task - Jetpack connection, Stripe not connected' => [ @@ -439,6 +454,7 @@ public function provider_onboarding_known_froms() { true, false, false, + false, 'onboarding_wizard', ], 'From Woo Payments task - Jetpack connection, Stripe connected' => [ @@ -447,6 +463,7 @@ public function provider_onboarding_known_froms() { true, true, false, + false, 'overview_page', ], 'From Connect page - no Jetpack connection, Stripe connected' => [ @@ -455,6 +472,7 @@ public function provider_onboarding_known_froms() { false, true, false, + false, 'start_jetpack_connection', ], 'From Connect page - Jetpack connection, Stripe not connected' => [ @@ -463,6 +481,7 @@ public function provider_onboarding_known_froms() { true, false, false, + false, 'onboarding_wizard', ], 'From Connect page - Jetpack connection, Stripe connected' => [ @@ -471,6 +490,7 @@ public function provider_onboarding_known_froms() { true, true, false, + false, 'overview_page', ], 'From Connect page - no Jetpack connection, Stripe connected - test-drive' => [ @@ -479,6 +499,7 @@ public function provider_onboarding_known_froms() { false, true, true, + false, 'start_jetpack_connection', ], 'From Connect page - Jetpack connection, Stripe not connected - test-drive' => [ @@ -487,6 +508,7 @@ public function provider_onboarding_known_froms() { true, false, true, + false, 'init_stripe_onboarding', ], 'From Connect page - Jetpack connection, Stripe connected - test-drive' => [ @@ -495,14 +517,25 @@ public function provider_onboarding_known_froms() { true, true, true, + false, 'overview_page', ], + 'From Connect page - Jetpack connection, Stripe connected - test-drive, redirect to settings' => [ + WC_Payments_Onboarding_Service::FROM_CONNECT_PAGE, + WC_Payments_Onboarding_Service::SOURCE_WCADMIN_SETTINGS_PAGE, // Some other original source. + true, + true, + true, + true, + 'settings_page', + ], 'From Woo Payments Settings - no Jetpack connection, Stripe connected' => [ WC_Payments_Onboarding_Service::FROM_WCADMIN_PAYMENTS_SETTINGS, WC_Payments_Onboarding_Service::SOURCE_WCADMIN_SETTINGS_PAGE, false, true, false, + false, 'connect_page', ], 'From Woo Payments Settings - Jetpack connection, Stripe not connected' => [ @@ -511,6 +544,7 @@ public function provider_onboarding_known_froms() { true, false, false, + false, 'connect_page', ], 'From Woo Payments Settings - Jetpack connection, Stripe connected' => [ @@ -519,6 +553,7 @@ public function provider_onboarding_known_froms() { true, true, false, + false, 'overview_page', ], 'From Incentive page - no Jetpack connection, Stripe connected' => [ @@ -527,6 +562,7 @@ public function provider_onboarding_known_froms() { false, true, false, + false, 'start_jetpack_connection', ], 'From Incentive page - Jetpack connection, Stripe not connected' => [ @@ -535,6 +571,7 @@ public function provider_onboarding_known_froms() { true, false, false, + false, 'onboarding_wizard', ], 'From Incentive page - Jetpack connection, Stripe connected' => [ @@ -543,6 +580,7 @@ public function provider_onboarding_known_froms() { true, true, false, + false, 'overview_page', ], // This is a weird scenario that should not happen under normal circumstances. @@ -552,6 +590,7 @@ public function provider_onboarding_known_froms() { false, false, false, + false, 'connect_page', ], 'From Onboarding wizard - Jetpack connection, Stripe not connected' => [ @@ -560,6 +599,7 @@ public function provider_onboarding_known_froms() { true, false, false, + false, 'init_stripe_onboarding', ], 'From Onboarding wizard - Jetpack connection, Stripe connected' => [ @@ -568,6 +608,7 @@ public function provider_onboarding_known_froms() { true, true, false, + false, 'overview_page', ], ]; diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php index d02681e42d9..d7c6ca45c0d 100644 --- a/tests/unit/test-class-wc-payments-order-service.php +++ b/tests/unit/test-class-wc-payments-order-service.php @@ -856,7 +856,7 @@ public function test_mark_payment_dispute_created() { $this->assertStringContainsString( $amount, $notes[0]->content ); $this->assertStringContainsString( 'Product not received', $notes[0]->content ); $this->assertStringContainsString( $deadline, $notes[0]->content ); - $this->assertStringContainsString( '/payments/transactions/details&id=ch_123" target="_blank" rel="noopener noreferrer">Response due by', $notes[0]->content ); + $this->assertStringContainsString( '%2Fpayments%2Ftransactions%2Fdetails&id=ch_123" target="_blank" rel="noopener noreferrer">Response due by', $notes[0]->content ); // Assert: Check that order status change note was added. $this->assertStringContainsString( 'Pending payment to On hold', $notes[1]->content ); @@ -910,7 +910,7 @@ public function test_mark_payment_dispute_closed_with_status_won() { $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); $this->assertStringContainsString( 'Pending payment to Completed', $notes[1]->content ); $this->assertStringContainsString( 'Payment dispute has been closed with status won', $notes[0]->content ); - $this->assertStringContainsString( '/payments/transactions/details&id=ch_123" target="_blank" rel="noopener noreferrer">dispute overview', $notes[0]->content ); + $this->assertStringContainsString( '%2Fpayments%2Ftransactions%2Fdetails&id=ch_123" target="_blank" rel="noopener noreferrer">dispute overview', $notes[0]->content ); // Assert: Applying the same data multiple times does not cause duplicate actions. $this->order_service->mark_payment_dispute_closed( $this->order, $charge_id, $status ); @@ -938,7 +938,7 @@ public function test_mark_payment_dispute_closed_with_status_lost() { $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); $this->assertStringContainsString( 'On hold to Refunded', $notes[1]->content ); $this->assertStringContainsString( 'Payment dispute has been closed with status lost', $notes[0]->content ); - $this->assertStringContainsString( '/payments/transactions/details&id=ch_123" target="_blank" rel="noopener noreferrer">dispute overview', $notes[0]->content ); + $this->assertStringContainsString( '%2Fpayments%2Ftransactions%2Fdetails&id=ch_123" target="_blank" rel="noopener noreferrer">dispute overview', $notes[0]->content ); // Assert: Check for created refund, and the amount is correct. $refunds = $this->order->get_refunds(); diff --git a/tests/unit/test-class-wc-payments-payment-request-button-handler.php b/tests/unit/test-class-wc-payments-payment-request-button-handler.php index 993534a2530..28bfdfb064e 100644 --- a/tests/unit/test-class-wc-payments-payment-request-button-handler.php +++ b/tests/unit/test-class-wc-payments-payment-request-button-handler.php @@ -258,143 +258,6 @@ private static function get_shipping_option_rate_id( $instance_id ) { return $method->get_rate_id(); } - public function test_tokenized_cart_address_avoid_normalization_when_missing_header() { - $request = new WP_REST_Request(); - $request->set_header( 'X-WooPayments-Tokenized-Cart', null ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_param( - 'shipping_address', - [ - 'country' => 'US', - 'state' => 'California', - ] - ); - - $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); - - $shipping_address = $request->get_param( 'shipping_address' ); - - $this->assertSame( 'California', $shipping_address['state'] ); - } - - public function test_tokenized_cart_address_avoid_normalization_when_wrong_nonce() { - $request = new WP_REST_Request(); - $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', 'invalid-nonce' ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_param( - 'shipping_address', - [ - 'country' => 'US', - 'state' => 'California', - ] - ); - - $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); - - $shipping_address = $request->get_param( 'shipping_address' ); - - $this->assertSame( 'California', $shipping_address['state'] ); - } - - public function test_tokenized_cart_address_state_normalization() { - $request = new WP_REST_Request(); - $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_param( - 'shipping_address', - [ - 'country' => 'US', - 'state' => 'California', - ] - ); - $request->set_param( - 'billing_address', - [ - 'country' => 'CA', - 'state' => 'Colombie-Britannique', - ] - ); - - $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); - - $shipping_address = $request->get_param( 'shipping_address' ); - $billing_address = $request->get_param( 'billing_address' ); - - $this->assertSame( 'CA', $shipping_address['state'] ); - $this->assertSame( 'BC', $billing_address['state'] ); - } - - public function test_tokenized_cart_address_postcode_normalization() { - $request = new WP_REST_Request(); - $request->set_route( '/wc/store/v1/cart/update-customer' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_param( - 'shipping_address', - [ - 'country' => 'CA', - 'postcode' => 'H3B', - ] - ); - $request->set_param( - 'billing_address', - [ - 'country' => 'US', - 'postcode' => '90210', - ] - ); - - $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); - - $shipping_address = $request->get_param( 'shipping_address' ); - $billing_address = $request->get_param( 'billing_address' ); - - // this should be modified. - $this->assertSame( 'H3B000', $shipping_address['postcode'] ); - // this shouldn't be modified. - $this->assertSame( '90210', $billing_address['postcode'] ); - } - - public function test_tokenized_cart_avoid_address_postcode_normalization_if_route_incorrect() { - $request = new WP_REST_Request(); - $request->set_route( '/wc/store/v1/checkout' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart', 'true' ); - $request->set_header( 'X-WooPayments-Tokenized-Cart-Nonce', wp_create_nonce( 'woopayments_tokenized_cart_nonce' ) ); - $request->set_header( 'Content-Type', 'application/json' ); - $request->set_param( - 'shipping_address', - [ - 'country' => 'CA', - 'postcode' => 'H3B', - 'state' => 'Colombie-Britannique', - ] - ); - $request->set_param( - 'billing_address', - [ - 'country' => 'CA', - 'postcode' => 'H3B', - 'state' => 'Colombie-Britannique', - ] - ); - - $this->pr->tokenized_cart_store_api_address_normalization( null, null, $request ); - - $shipping_address = $request->get_param( 'shipping_address' ); - $billing_address = $request->get_param( 'billing_address' ); - - // this should be modified. - $this->assertSame( 'BC', $shipping_address['state'] ); - $this->assertSame( 'BC', $billing_address['state'] ); - // this shouldn't be modified. - $this->assertSame( 'H3B', $shipping_address['postcode'] ); - $this->assertSame( 'H3B', $billing_address['postcode'] ); - } - - public function test_multiple_packages_in_cart_not_allowed() { // Add fake packages to the cart. add_filter( diff --git a/tests/unit/test-class-wc-payments-redirect-service.php b/tests/unit/test-class-wc-payments-redirect-service.php index 931d0e18102..2d2a8173d9a 100644 --- a/tests/unit/test-class-wc-payments-redirect-service.php +++ b/tests/unit/test-class-wc-payments-redirect-service.php @@ -268,4 +268,15 @@ public function test_redirect_to_connect_page_redirects_with_additional_params() // Act. $this->redirect_service->redirect_to_connect_page( null, null, [ 'source' => 'some-source' ] ); } + + public function test_redirect_to_settings_page_redirects() { + // Assert. + $this->redirect_service + ->expects( $this->once() ) + ->method( 'redirect_to' ) + ->with( admin_url( 'admin.php?page=wc-settings&tab=checkout' ) ); + + // Act. + $this->redirect_service->redirect_to_settings_page(); + } } diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index bf58120532c..d376b1491fd 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -1733,4 +1733,59 @@ public function provider_mode_mismatch_detection() { 'No mode proceeds' => [ null, $this->once() ], ]; } + + public function test_process_throws_exception_when_order_not_found_for_successful_intent_id() { + $this->event_body['type'] = 'payment_intent.succeeded'; + $this->event_body['data']['object'] = [ + 'id' => 'unresolvable_intent_id', + 'currency' => 'usd', + 'metadata' => [], + 'charges' => [ + 'data' => [], + ], + ]; + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->with( 'unresolvable_intent_id' ) + ->willReturn( null ); + + $this->mock_order + ->expects( $this->never() ) + ->method( 'save' ); + + $this->expectException( WCPay\Exceptions\Invalid_Payment_Method_Exception::class ); + $this->expectExceptionMessage( 'Could not find order via intent ID: unresolvable_intent_id' ); + + $this->webhook_processing_service->process( $this->event_body ); + } + + public function test_process_throws_exception_when_refund_found_for_successful_intent_id() { + $mock_refund = $this->createMock( WC_Order_Refund::class ); + $this->event_body['type'] = 'payment_intent.succeeded'; + $this->event_body['data']['object'] = [ + 'id' => 'intent_id', + 'currency' => 'usd', + 'metadata' => [], + 'charges' => [ + 'data' => [], + ], + ]; + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->with( 'intent_id' ) + ->willReturn( $mock_refund ); + + $mock_refund + ->expects( $this->never() ) + ->method( 'save' ); + + $this->expectException( WCPay\Exceptions\Invalid_Payment_Method_Exception::class ); + $this->expectExceptionMessage( 'Could not find order via intent ID: intent_id' ); + + $this->webhook_processing_service->process( $this->event_body ); + } } diff --git a/woocommerce-payments.php b/woocommerce-payments.php index d8c44185b72..dcd04641ef6 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -11,7 +11,7 @@ * WC tested up to: 9.4.0 * Requires at least: 6.0 * Requires PHP: 7.3 - * Version: 8.5.0 + * Version: 8.5.1 * Requires Plugins: woocommerce * * @package WooCommerce\Payments