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/actions/e2e-pw/run-log-tests/action.yml b/.github/actions/e2e-pw/run-log-tests/action.yml index d8f85f00285..b8244fc375e 100644 --- a/.github/actions/e2e-pw/run-log-tests/action.yml +++ b/.github/actions/e2e-pw/run-log-tests/action.yml @@ -9,7 +9,7 @@ runs: # Use +e to trap errors when running E2E tests. shell: /bin/bash +e {0} run: npm run test:e2e-pw-ci - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report 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/css/success.css b/assets/css/success.css index f11e45dd18a..b07595fda5d 100644 --- a/assets/css/success.css +++ b/assets/css/success.css @@ -13,3 +13,7 @@ .wc-payment-gateway-method-logo-wrapper.wc-payment-lpm-logo img { max-height: 26px; } + +.wc-payment-gateway-method-logo-wrapper.wc-payment-card-logo img { + max-height: 1em; +} diff --git a/assets/images/cards/jcb.svg b/assets/images/cards/jcb.svg index ae366edf606..697c616c071 100644 --- a/assets/images/cards/jcb.svg +++ b/assets/images/cards/jcb.svg @@ -1,11 +1 @@ - - - - - - - - - - - + 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 d97e8e7ba85..969e9401b6c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,38 @@ *** WooPayments Changelog *** += 8.6.0 - 2024-12-04 = +* Add - Add Bank reference key column in Payout reports. This will help reconcile WooPayments Payouts with bank statements. +* Add - Display credit card brand icons on order received page. +* Fix - Add support to load stripe js asynchronously when it is not immediately available in the global scope. +* Fix - Add the missing "Download" column heading label and toggle menu option to the Payments → Documents list view table. +* Fix - Ensure ECE button load events are triggered for multiple buttons on the same page. +* Fix - Ensure ECE is displayed correctly taking into account the tax settings. +* Fix - Evidence submission is no longer available for Klarna inquiries as this is not supported by Stripe / Klarna. +* Fix - fix: express checkout to use its own css files. +* Fix - fix: missing ece is_product_page checks +* Fix - Fix ECE Tracks events not triggering when WooPay is disabled. +* Fix - Fix WooPay component spacing. +* Fix - Fix WooPay trial subscriptions purchases. +* Fix - Make sure the status of manual capture enablement is fetched from the right place. +* Fix - Prevent express checkout from being used if cart total becomes zero after coupon usage. +* Fix - Resolved issue with terminal payments in the payment intent failed webhook processing. +* Fix - Set the support phone field as mandatory in the settings page. +* Fix - Update Link logo alignment issue when WooPay is enabled and a specific version of Gutenberg is enabled. +* Fix - Use paragraph selector instead of label for pmme appearance +* Fix - Validate required billing fields using data from objects instead of checking the labels. +* Update - Avoid getting the appearance for pay for order page with the wrong appearance key. +* Update - chore: rename wrapper from payment-request to express-checkout +* Update - feat: add `wcpay_checkout_use_plain_method_label` filter to allow themes or merchants to force the "plain" WooPayments label on shortcode checkout. +* Update - refactor: express checkout initialization page location checks +* Update - refactor: express checkout utility for button UI interactions +* Dev - Allow redirect to the settings page from WCPay connect +* Dev - chore: removed old PRB implementation for ApplePay/GooglePay in favor of the ECE implementation; cleaned up ECE feature flag; +* Dev - Disable visual regression testing from Playwright until a more reliable approach is defined. +* Dev - Ensure proper return types in the webhook processing service. +* Dev - fix: E_DEPRECATED on BNPL empty PMME +* Dev - Fix return types +* Dev - Update snapshots for E2E Playwright screenshots + = 8.5.1 - 2024-11-25 = * Fix - fix: remove "test mode" badge from shortcode checkout. 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, @@ -107,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 }; + } } /** @@ -162,7 +174,7 @@ export default class WCPayAPI { orderId = orderIdPartials[ 0 ]; } - const confirmPaymentOrSetup = () => { + const confirmPaymentOrSetup = async () => { const { locale, publishableKey } = this.options; const accountIdForIntentConfirmation = getConfig( 'accountIdForIntentConfirmation' @@ -170,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, } ); } @@ -188,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, } ); }; @@ -207,9 +221,9 @@ export default class WCPayAPI { result.error.setup_intent.id ); // In case this is being called via payment request button from a product page, - // the getConfig function won't work, so fallback to getPaymentRequestData. + // the getConfig function won't work, so fallback to getExpressCheckoutConfig. const ajaxUrl = - getPaymentRequestData( 'ajax_url' ) ?? + getExpressCheckoutConfig( 'ajax_url' ) ?? getConfig( 'ajaxUrl' ); const ajaxCall = this.request( ajaxUrl, { @@ -253,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; } /** @@ -310,37 +326,20 @@ export default class WCPayAPI { } ); } - /** - * Submits shipping address to get available shipping options - * from Payment Request button. - * - * @param {Object} shippingAddress Shipping details. - * @return {Promise} Promise for the request to the server. - */ - paymentRequestCalculateShippingOptions( shippingAddress ) { - return this.request( - getPaymentRequestAjaxURL( 'get_shipping_options' ), - { - security: getPaymentRequestData( 'nonce' )?.shipping, - is_product_page: getPaymentRequestData( 'is_product_page' ), - ...shippingAddress, - } - ); - } - /** * Updates cart with selected shipping option. * * @param {Object} shippingOption Shipping option. * @return {Promise} Promise for the request to the server. */ - paymentRequestUpdateShippingDetails( shippingOption ) { + expressCheckoutECEUpdateShippingDetails( shippingOption ) { return this.request( - getPaymentRequestAjaxURL( 'update_shipping_method' ), + getExpressCheckoutAjaxURL( 'ece_update_shipping_method' ), { - security: getPaymentRequestData( 'nonce' )?.update_shipping, + security: getExpressCheckoutConfig( 'nonce' )?.update_shipping, shipping_method: [ shippingOption.id ], - is_product_page: getPaymentRequestData( 'is_product_page' ), + is_product_page: + getExpressCheckoutConfig( 'button_context' ) === 'product', } ); } @@ -350,10 +349,13 @@ export default class WCPayAPI { * * @return {Promise} Promise for the request to the server. */ - paymentRequestGetCartDetails() { - return this.request( getPaymentRequestAjaxURL( 'get_cart_details' ), { - security: getPaymentRequestData( 'nonce' )?.get_cart_details, - } ); + expressCheckoutECEGetCartDetails() { + return this.request( + getExpressCheckoutAjaxURL( 'ece_get_cart_details' ), + { + security: getExpressCheckoutConfig( 'nonce' )?.get_cart_details, + } + ); } /** @@ -362,56 +364,30 @@ export default class WCPayAPI { * @param {Object} productData Product data. * @return {Promise} Promise for the request to the server. */ - paymentRequestAddToCart( productData ) { - return this.request( getPaymentRequestAjaxURL( 'add_to_cart' ), { - security: getPaymentRequestData( 'nonce' )?.add_to_cart, + expressCheckoutECEAddToCart( productData ) { + return this.request( getExpressCheckoutAjaxURL( 'add_to_cart' ), { + security: getExpressCheckoutConfig( 'nonce' )?.add_to_cart, ...productData, } ); } - /** - * Empty the cart. - * - * @param {number} bookingId Booking ID (optional). - * @return {Promise} Promise for the request to the server. - */ - paymentRequestEmptyCart( bookingId ) { - return this.request( getPaymentRequestAjaxURL( 'empty_cart' ), { - security: getPaymentRequestData( 'nonce' )?.empty_cart, - booking_id: bookingId, - } ); - } - /** * Get selected product data from variable product page. * * @param {Object} productData Product data. * @return {Promise} Promise for the request to the server. */ - paymentRequestGetSelectedProductData( productData ) { + expressCheckoutECEGetSelectedProductData( productData ) { return this.request( - getPaymentRequestAjaxURL( 'get_selected_product_data' ), + getExpressCheckoutAjaxURL( 'ece_get_selected_product_data' ), { - security: getPaymentRequestData( 'nonce' ) + security: getExpressCheckoutConfig( 'nonce' ) ?.get_selected_product_data, ...productData, } ); } - /** - * Creates order based on Payment Request payment method. - * - * @param {Object} paymentData Order data. - * @return {Promise} Promise for the request to the server. - */ - paymentRequestCreateOrder( paymentData ) { - return this.request( getPaymentRequestAjaxURL( 'create_order' ), { - _wpnonce: getPaymentRequestData( 'nonce' )?.checkout, - ...paymentData, - } ); - } - /** * Submits shipping address to get available shipping options * from Express Checkout ECE payment method. @@ -421,10 +397,11 @@ export default class WCPayAPI { */ expressCheckoutECECalculateShippingOptions( shippingAddress ) { return this.request( - getExpressCheckoutAjaxURL( 'get_shipping_options' ), + getExpressCheckoutAjaxURL( 'ece_get_shipping_options' ), { security: getExpressCheckoutConfig( 'nonce' )?.shipping, - is_product_page: getExpressCheckoutConfig( 'is_product_page' ), + is_product_page: + getExpressCheckoutConfig( 'button_context' ) === 'product', ...shippingAddress, } ); @@ -437,7 +414,7 @@ export default class WCPayAPI { * @return {Promise} Promise for the request to the server. */ expressCheckoutECECreateOrder( paymentData ) { - return this.request( getExpressCheckoutAjaxURL( 'create_order' ), { + return this.request( getExpressCheckoutAjaxURL( 'ece_create_order' ), { _wpnonce: getExpressCheckoutConfig( 'nonce' )?.checkout, ...paymentData, } ); @@ -451,7 +428,7 @@ export default class WCPayAPI { * @return {Promise} Promise for the request to the server. */ expressCheckoutECEPayForOrder( order, paymentData ) { - return this.request( getExpressCheckoutAjaxURL( 'pay_for_order' ), { + return this.request( getExpressCheckoutAjaxURL( 'ece_pay_for_order' ), { _wpnonce: getExpressCheckoutConfig( 'nonce' )?.pay_for_order, order, ...paymentData, @@ -491,14 +468,6 @@ export default class WCPayAPI { } ); } - paymentRequestPayForOrder( order, paymentData ) { - return this.request( getPaymentRequestAjaxURL( 'pay_for_order' ), { - _wpnonce: getPaymentRequestData( 'nonce' )?.pay_for_order, - order, - ...paymentData, - } ); - } - /** * Fetches the cart data from the woocommerce store api. * 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 68593bababd..cdb3d105861 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -18,12 +18,14 @@ import { SavedTokenHandler } from './saved-token-handler'; import PaymentMethodLabel from './payment-method-label'; import request from '../utils/request'; import enqueueFraudScripts from 'fraud-scripts'; -import paymentRequestPaymentMethod from '../../payment-request/blocks'; import { expressCheckoutElementApplePay, expressCheckoutElementGooglePay, } from '../../express-checkout/blocks'; -import tokenizedCartPaymentRequestPaymentMethod from '../../tokenized-payment-request/blocks'; +import { + tokenizedExpressCheckoutElementApplePay, + tokenizedExpressCheckoutElementGooglePay, +} from 'wcpay/tokenized-express-checkout/blocks'; import { PAYMENT_METHOD_NAME_CARD, @@ -101,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 @@ -161,15 +162,16 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } if ( getUPEConfig( 'isPaymentRequestEnabled' ) ) { - if ( getUPEConfig( 'isTokenizedCartPrbEnabled' ) ) { + if ( getUPEConfig( 'isTokenizedCartEceEnabled' ) ) { + registerExpressPaymentMethod( + tokenizedExpressCheckoutElementApplePay( api ) + ); registerExpressPaymentMethod( - tokenizedCartPaymentRequestPaymentMethod( api ) + tokenizedExpressCheckoutElementGooglePay( api ) ); - } else if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { + } else { registerExpressPaymentMethod( expressCheckoutElementApplePay( api ) ); registerExpressPaymentMethod( expressCheckoutElementGooglePay( api ) ); - } else { - registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); } } window.addEventListener( 'load', () => { 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 6e4b8fb43fe..fe53b9b2a88 100644 --- a/client/checkout/classic/event-handlers.js +++ b/client/checkout/classic/event-handlers.js @@ -1,4 +1,4 @@ -/* global jQuery */ +/* global jQuery, wc_address_i18n_params */ /** * Internal dependencies @@ -33,7 +33,17 @@ import { recordUserEvent } from 'tracks'; import { SHORTCODE_BILLING_ADDRESS_FIELDS } from 'wcpay/checkout/constants'; import '../utils/copy-test-number'; +function getParsedLocale() { + try { + return JSON.parse( + wc_address_i18n_params.locale.replace( /"/g, '"' ) + ); + } catch ( e ) { + return null; + } +} jQuery( function ( $ ) { + const locale = getParsedLocale(); enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); const publishableKey = getUPEConfig( 'publishableKey' ); @@ -112,7 +122,7 @@ jQuery( function ( $ ) { } } ); - if ( $addPaymentMethodForm.length || $payForOrderForm.length ) { + if ( $addPaymentMethodForm.length ) { maybeMountStripePaymentElement( 'add_payment_method' ); } @@ -249,7 +259,7 @@ jQuery( function ( $ ) { } function isBillingInformationMissing() { - const billingFieldsDisplayed = getUPEConfig( 'enabledBillingFields' ); + const enabledBillingFields = getUPEConfig( 'enabledBillingFields' ); // first name and last name are kinda special - we just need one of them to be at checkout const name = `${ @@ -263,12 +273,12 @@ jQuery( function ( $ ) { }`.trim(); if ( ! name && - ( billingFieldsDisplayed.includes( + ( enabledBillingFields[ SHORTCODE_BILLING_ADDRESS_FIELDS.first_name - ) || - billingFieldsDisplayed.includes( + ] || + enabledBillingFields[ SHORTCODE_BILLING_ADDRESS_FIELDS.last_name - ) ) + ] ) ) { return true; } @@ -279,16 +289,29 @@ jQuery( function ( $ ) { SHORTCODE_BILLING_ADDRESS_FIELDS.address_1, SHORTCODE_BILLING_ADDRESS_FIELDS.city, SHORTCODE_BILLING_ADDRESS_FIELDS.postcode, - ].filter( ( field ) => billingFieldsDisplayed.includes( field ) ); + ].filter( ( field ) => enabledBillingFields[ field ] ); + + const country = billingFieldsToValidate.includes( + SHORTCODE_BILLING_ADDRESS_FIELDS.country + ) + ? document.querySelector( + `#${ SHORTCODE_BILLING_ADDRESS_FIELDS.country }` + )?.value + : null; // We need to just find one field with missing information. If even only one is missing, just return early. return Boolean( billingFieldsToValidate.find( ( fieldName ) => { const $field = document.querySelector( `#${ fieldName }` ); - const $formRow = $field.closest( '.form-row' ); - const isRequired = $formRow.classList.contains( - 'validate-required' - ); + let isRequired = enabledBillingFields[ fieldName ]?.required; + + if ( country && locale && fieldName !== 'billing_email' ) { + const key = fieldName.replace( 'billing_', '' ); + isRequired = + locale[ country ][ key ]?.required ?? + locale.default[ key ]?.required; + } + const hasValue = $field?.value; return isRequired && ! hasValue; 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/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/constants.js b/client/checkout/constants.js index 7da1460c913..1c9b616fe58 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -11,8 +11,6 @@ export const PAYMENT_METHOD_NAME_AFFIRM = 'woocommerce_payments_affirm'; export const PAYMENT_METHOD_NAME_AFTERPAY = 'woocommerce_payments_afterpay_clearpay'; export const PAYMENT_METHOD_NAME_KLARNA = 'woocommerce_payments_klarna'; -export const PAYMENT_METHOD_NAME_PAYMENT_REQUEST = - 'woocommerce_payments_payment_request'; export const PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT = 'woocommerce_payments_express_checkout'; export const PAYMENT_METHOD_NAME_WOOPAY_EXPRESS_CHECKOUT = diff --git a/client/checkout/express-checkout-buttons.scss b/client/checkout/express-checkout-buttons.scss index b20ac8a5be8..be3e02845d8 100644 --- a/client/checkout/express-checkout-buttons.scss +++ b/client/checkout/express-checkout-buttons.scss @@ -1,4 +1,4 @@ -.wcpay-payment-request-wrapper { +.wcpay-express-checkout-wrapper { margin-top: 1em; width: 100%; clear: both; @@ -21,7 +21,7 @@ } // This fixes width calculation issues inside the iframe for blocks and shortcode pages. -.wcpay-payment-request-wrapper, +.wcpay-express-checkout-wrapper, .wc-block-components-express-payment__event-buttons { .StripeElement iframe { max-width: unset; 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/utils/upe.js b/client/checkout/utils/upe.js index 504f3495f07..761088fa664 100644 --- a/client/checkout/utils/upe.js +++ b/client/checkout/utils/upe.js @@ -64,33 +64,19 @@ export const getSelectedUPEGatewayPaymentMethod = () => { export const getHiddenBillingFields = ( enabledBillingFields ) => { return { name: - enabledBillingFields.includes( 'billing_first_name' ) || - enabledBillingFields.includes( 'billing_last_name' ) + enabledBillingFields.billing_first_name || + enabledBillingFields.billing_last_name ? 'never' : 'auto', - email: enabledBillingFields.includes( 'billing_email' ) - ? 'never' - : 'auto', - phone: enabledBillingFields.includes( 'billing_phone' ) - ? 'never' - : 'auto', + email: enabledBillingFields.billing_email ? 'never' : 'auto', + phone: enabledBillingFields.billing_phone ? 'never' : 'auto', address: { - country: enabledBillingFields.includes( 'billing_country' ) - ? 'never' - : 'auto', - line1: enabledBillingFields.includes( 'billing_address_1' ) - ? 'never' - : 'auto', - line2: enabledBillingFields.includes( 'billing_address_2' ) - ? 'never' - : 'auto', - city: enabledBillingFields.includes( 'billing_city' ) - ? 'never' - : 'auto', - state: enabledBillingFields.includes( 'billing_state' ) - ? 'never' - : 'auto', - postalCode: enabledBillingFields.includes( 'billing_postcode' ) + country: enabledBillingFields.billing_country ? 'never' : 'auto', + line1: enabledBillingFields.billing_address_1 ? 'never' : 'auto', + line2: enabledBillingFields.billing_address_2 ? 'never' : 'auto', + city: enabledBillingFields.billing_city ? 'never' : 'auto', + state: enabledBillingFields.billing_state ? 'never' : 'auto', + postalCode: enabledBillingFields.billing_postcode ? 'never' : 'auto', }, 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/components/woopay/save-user/test/checkout-page-save-user.test.js b/client/components/woopay/save-user/test/checkout-page-save-user.test.js index 9986e3868eb..a09b4249053 100644 --- a/client/components/woopay/save-user/test/checkout-page-save-user.test.js +++ b/client/components/woopay/save-user/test/checkout-page-save-user.test.js @@ -160,6 +160,11 @@ describe( 'CheckoutPageSaveUser', () => { 'Securely save my information for 1-click checkout' ) ).not.toBeChecked(); + expect( + screen.queryAllByLabelText( + 'Securely save my information for 1-click checkout' + ) + ).toHaveLength( 1 ); } ); it( 'should not render checkbox for saving WooPay user when user is already registered', () => { @@ -184,7 +189,7 @@ describe( 'CheckoutPageSaveUser', () => { ).not.toBeInTheDocument(); } ); - it( 'should render checkbox for saving WooPay user when selected payment method is not card', () => { + it( 'should not render checkbox for saving WooPay user when selected payment method is not card', () => { useSelectedPaymentMethod.mockImplementation( () => ( { isWCPayChosen: false, } ) ); @@ -235,6 +240,7 @@ describe( 'CheckoutPageSaveUser', () => { expect( label ).toBeChecked(); expect( screen.queryByTestId( 'save-user-form' ) ).toBeInTheDocument(); + expect( screen.getAllByTestId( 'save-user-form' ) ).toHaveLength( 1 ); } ); it( 'should not call `request` on classic checkout when checkbox is clicked', () => { 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/documents/list/index.tsx b/client/documents/list/index.tsx index 307c3faf0c6..b69e2df54af 100644 --- a/client/documents/list/index.tsx +++ b/client/documents/list/index.tsx @@ -55,7 +55,7 @@ const getColumns = (): Column[] => }, { key: 'download', - label: '', + label: __( 'Download', 'woocommerce-payments' ), screenReaderLabel: __( 'Download', 'woocommerce-payments' ), isLeftAligned: false, isNumeric: true, @@ -197,7 +197,8 @@ export const DocumentsList = (): JSX.Element => { value: getDocumentUrl( document.document_id ), display: ( - + + + ) } + ), + } ) +); jest.mock( '@stripe/react-stripe-js', () => ( { - PaymentRequestButtonElement: jest.fn( () => ( - - ) ), useStripe: jest.fn(), } ) ); @@ -53,8 +53,6 @@ const render = ( ui, options ) => } ); describe( 'PaymentRequestButtonPreview', () => { - const canMakePaymentMock = jest.fn(); - let location; const mockHttpsLocation = new URL( 'https://example.com' ); @@ -64,14 +62,6 @@ describe( 'PaymentRequestButtonPreview', () => { location = global.location; delete global.location; global.location = mockHttpsLocation; - - shouldUseGooglePayBrand.mockReturnValue( true ); - useStripe.mockReturnValue( { - paymentRequest: () => ( { - canMakePayment: canMakePaymentMock, - } ), - } ); - canMakePaymentMock.mockResolvedValue( {} ); } ); afterEach( () => { @@ -82,64 +72,7 @@ describe( 'PaymentRequestButtonPreview', () => { } ); } ); - it( 'displays Google Chrome and Google Pay when page is in Safari', async () => { - shouldUseGooglePayBrand.mockReturnValue( false ); - - render( ); - - expect( - await screen.findByText( 'Stripe button mock' ) - ).toBeInTheDocument(); - expect( - screen.queryByText( /Safari/, { - ignore: '.a11y-speak-region', - } ) - ).not.toBeInTheDocument(); - } ); - - it( 'displays Safari Apple Pay when page is in Google Chrome', async () => { - shouldUseGooglePayBrand.mockReturnValue( true ); - - render( ); - - expect( - await screen.findByText( 'Stripe button mock' ) - ).toBeInTheDocument(); - expect( - screen.queryByText( /Chrome/, { - ignore: '.a11y-speak-region', - } ) - ).not.toBeInTheDocument(); - } ); - - it( 'does not display anything if stripe is falsy', () => { - useStripe.mockReturnValue( null ); - - render( ); - - expect( - screen.queryByText( 'Stripe button mock' ) - ).not.toBeInTheDocument(); - } ); - - it( 'displays an info notice if stripe fails to load', async () => { - canMakePaymentMock.mockResolvedValue( null ); - render( ); - - expect( - await screen.findByText( - /To preview the express checkout buttons, ensure your store uses/, - { - ignore: '.a11y-speak-region', - } - ) - ).toBeInTheDocument(); - expect( - screen.queryByText( 'Stripe button mock' ) - ).not.toBeInTheDocument(); - } ); - - it( 'displays the payment button when stripe is loaded', async () => { + it( 'displays the button preview', async () => { render( ); expect( diff --git a/client/settings/express-checkout-settings/test/payment-request-settings.test.js b/client/settings/express-checkout-settings/test/payment-request-settings.test.js index 129d657444e..47e5a61de2b 100644 --- a/client/settings/express-checkout-settings/test/payment-request-settings.test.js +++ b/client/settings/express-checkout-settings/test/payment-request-settings.test.js @@ -35,7 +35,7 @@ jest.mock( '../payment-request-button-preview' ); PaymentRequestButtonPreview.mockImplementation( () => '<>' ); jest.mock( 'utils/express-checkout', () => ( { - getPaymentRequestData: jest.fn().mockReturnValue( { + getExpressCheckoutConfig: jest.fn().mockReturnValue( { publishableKey: '123', accountId: '0001', locale: 'en', diff --git a/client/settings/express-checkout/link-item.tsx b/client/settings/express-checkout/link-item.tsx index 080cd81a728..b9cc98e4de5 100644 --- a/client/settings/express-checkout/link-item.tsx +++ b/client/settings/express-checkout/link-item.tsx @@ -59,27 +59,33 @@ const LinkExpressCheckoutItem = (): React.ReactElement => {
{ isWooPayEnabled ? ( - -
- -
- - { __( - 'Link by Stripe cannot be enabled at checkout. Click to expand.', +
+
+ + -
+ > +
+ +
+ + { __( + 'Link by Stripe cannot be enabled at checkout. Click to expand.', + 'woocommerce-payments' + ) } + +
+
+ +
- +
) : ( {
{ isStripeLinkEnabled ? ( - -
- -
- - { __( - 'WooPay cannot be enabled at checkout. Click to expand.', +
+
+ + -
+ > +
+ +
+ + { __( + 'WooPay cannot be enabled at checkout. Click to expand.', + 'woocommerce-payments' + ) } + +
+
+ +
- +
) : ( { ) } checked={ isWooPayEnabled } onChange={ updateIsWooPayEnabled } + data-testid="woopay-toggle" /> ) }
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/settings/wcpay-settings-context.js b/client/settings/wcpay-settings-context.js index 36d8d92b029..531e806ff27 100644 --- a/client/settings/wcpay-settings-context.js +++ b/client/settings/wcpay-settings-context.js @@ -11,7 +11,6 @@ const WCPaySettingsContext = createContext( { isAuthAndCaptureEnabled: false, isDisputeIssuerEvidenceEnabled: false, woopay: false, - isStripeEceEnabled: false, }, } ); 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 new file mode 100644 index 00000000000..9975b56d406 --- /dev/null +++ b/client/tokenized-express-checkout/blocks/components/express-checkout-component.js @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; +/** + * Internal dependencies + */ +import { + shippingAddressChangeHandler, + shippingRateChangeHandler, +} from '../../event-handlers'; +import { useExpressCheckout } from '../hooks/use-express-checkout'; +import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/constants'; + +const getPaymentMethodsOverride = ( enabledPaymentMethod ) => { + const allDisabled = { + amazonPay: 'never', + applePay: 'never', + googlePay: 'never', + link: 'never', + paypal: 'never', + }; + + const enabledParam = [ 'applePay', 'googlePay' ].includes( + enabledPaymentMethod + ) + ? 'always' + : 'auto'; + + return { + paymentMethods: { + ...allDisabled, + [ enabledPaymentMethod ]: enabledParam, + }, + }; +}; + +// Visual adjustments to horizontally align the buttons. +const adjustButtonHeights = ( buttonOptions, expressPaymentMethod ) => { + // Apple Pay has a nearly imperceptible height difference. We increase it by 1px here. + if ( buttonOptions.buttonTheme.applePay === 'black' ) { + if ( expressPaymentMethod === 'applePay' ) { + buttonOptions.buttonHeight = buttonOptions.buttonHeight + 0.4; + } + } + + // GooglePay with the white theme has a 2px height difference due to its border. + if ( + expressPaymentMethod === 'googlePay' && + buttonOptions.buttonTheme.googlePay === 'white' + ) { + buttonOptions.buttonHeight = buttonOptions.buttonHeight - 2; + } + + // Clamp the button height to the allowed range 40px to 55px. + buttonOptions.buttonHeight = Math.max( + 40, + Math.min( buttonOptions.buttonHeight, 55 ) + ); + return buttonOptions; +}; + +/** + * ExpressCheckout express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +const ExpressCheckoutComponent = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, + expressPaymentMethod = '', + buttonAttributes, + isPreview = false, +} ) => { + const { + buttonOptions, + onButtonClick, + onConfirm, + onReady, + onCancel, + elements, + } = useExpressCheckout( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, + } ); + const onClickHandler = ! isPreview ? onButtonClick : () => {}; + const onShippingAddressChange = ( event ) => + shippingAddressChangeHandler( event, elements ); + + const onShippingRateChange = ( event ) => + shippingRateChangeHandler( event, elements ); + + const onElementsReady = ( event ) => { + const paymentMethodContainer = document.getElementById( + `express-payment-method-${ PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT }_${ expressPaymentMethod }` + ); + + const availablePaymentMethods = event.availablePaymentMethods || {}; + + if ( + paymentMethodContainer && + ! availablePaymentMethods[ expressPaymentMethod ] + ) { + paymentMethodContainer.remove(); + } + + // Any actions that WooPayments needs to perform. + onReady( event ); + }; + + // The Cart & Checkout blocks provide unified styles across all buttons, + // which should override the extension specific settings. + const withBlockOverride = () => { + const override = {}; + if ( typeof buttonAttributes !== 'undefined' ) { + override.buttonHeight = Number( buttonAttributes.height ); + } + return { + ...buttonOptions, + ...override, + }; + }; + + return ( + + ); +}; + +export default ExpressCheckoutComponent; diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-container.js b/client/tokenized-express-checkout/blocks/components/express-checkout-container.js new file mode 100644 index 00000000000..400440059f0 --- /dev/null +++ b/client/tokenized-express-checkout/blocks/components/express-checkout-container.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { useMemo } from 'react'; +import { Elements } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import ExpressCheckoutComponent from './express-checkout-component'; +import { + getExpressCheckoutButtonAppearance, + getExpressCheckoutData, +} from '../../utils'; +import '../express-checkout-element.scss'; + +const ExpressCheckoutContainer = ( props ) => { + const { api, billing, buttonAttributes, isPreview } = props; + + const stripePromise = useMemo( () => { + return api.loadStripeForExpressCheckout(); + }, [ api ] ); + + const options = { + mode: 'payment', + paymentMethodCreation: 'manual', + amount: ! isPreview ? billing.cartTotal.value : 10, + currency: ! isPreview ? billing.currency.code.toLowerCase() : 'usd', + appearance: getExpressCheckoutButtonAppearance( buttonAttributes ), + locale: getExpressCheckoutData( 'stripe' )?.locale ?? 'en', + }; + + return ( +
+ + + +
+ ); +}; + +export default ExpressCheckoutContainer; diff --git a/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js b/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js new file mode 100644 index 00000000000..58e5ea2aaae --- /dev/null +++ b/client/tokenized-express-checkout/blocks/components/express-checkout-preview.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { useState } from 'react'; +import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import InlineNotice from 'components/inline-notice'; +import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout'; + +export const ExpressCheckoutPreviewComponent = ( { + stripe, + buttonType, + theme, + height, + radius, +} ) => { + const [ canRenderButtons, setCanRenderButtons ] = useState( true ); + + const options = { + mode: 'payment', + amount: 1000, + currency: 'usd', + appearance: { + variables: { + borderRadius: `${ radius ?? getDefaultBorderRadius() }px`, + spacingUnit: '6px', + }, + }, + }; + + const mapThemeConfigToButtonTheme = ( paymentMethod, buttonTheme ) => { + switch ( buttonTheme ) { + case 'dark': + return 'black'; + case 'light': + return 'white'; + case 'light-outline': + if ( paymentMethod === 'googlePay' ) { + return 'white'; + } + + return 'white-outline'; + default: + return 'black'; + } + }; + + const type = buttonType === 'default' ? 'plain' : buttonType; + + const buttonOptions = { + buttonHeight: Math.min( Math.max( height, 40 ), 55 ), + buttonTheme: { + googlePay: mapThemeConfigToButtonTheme( 'googlePay', theme ), + applePay: mapThemeConfigToButtonTheme( 'applePay', theme ), + }, + buttonType: { + googlePay: type, + applePay: type, + }, + paymentMethods: { + link: 'never', + googlePay: 'always', + applePay: 'always', + }, + layout: { overflow: 'never' }, + }; + + const onReady = ( { availablePaymentMethods } ) => { + if ( availablePaymentMethods ) { + setCanRenderButtons( true ); + } else { + setCanRenderButtons( false ); + } + }; + + if ( canRenderButtons ) { + return ( +
+ + {} } + onReady={ onReady } + /> + +
+ ); + } + + return ( + + { __( + 'Failed to preview the Apple Pay or Google Pay button. ' + + 'Ensure your store uses HTTPS on a publicly available domain ' + + "and you're viewing this page in a Safari or Chrome browser. " + + 'Your device must be configured to use Apple Pay or Google Pay.', + 'woocommerce-payments' + ) } + + ); +}; diff --git a/client/tokenized-express-checkout/blocks/express-checkout-element.scss b/client/tokenized-express-checkout/blocks/express-checkout-element.scss new file mode 100644 index 00000000000..4b4e3d5e29e --- /dev/null +++ b/client/tokenized-express-checkout/blocks/express-checkout-element.scss @@ -0,0 +1,23 @@ +// Cart Block +.wc-block-components-express-payment--cart { + .wc-block-components-express-payment__event-buttons > li { + padding-bottom: 12px !important; + + &:last-child { + padding-bottom: 0 !important; + } + } +} + +// OR separator +.wc-block-components-express-payment-continue-rule--cart { + margin: 24px 0 !important; + height: 20px; +} + +.wc-block-components-express-payment + .wc-block-components-express-payment__event-buttons + > li { + margin-left: 1px !important; + width: 99% !important; +} diff --git a/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js new file mode 100644 index 00000000000..f33604354f7 --- /dev/null +++ b/client/tokenized-express-checkout/blocks/hooks/use-express-checkout.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useStripe, useElements } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import { + getExpressCheckoutButtonStyleSettings, + getExpressCheckoutData, + normalizeLineItems, +} from '../../utils'; +import { + onAbortPaymentHandler, + onCancelHandler, + onClickHandler, + onCompletePaymentHandler, + onConfirmHandler, + onReadyHandler, +} from '../../event-handlers'; + +export const useExpressCheckout = ( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, +} ) => { + const stripe = useStripe(); + const elements = useElements(); + + const buttonOptions = getExpressCheckoutButtonStyleSettings(); + + const onCancel = () => { + onCancelHandler(); + onClose(); + }; + + const completePayment = ( redirectUrl ) => { + onCompletePaymentHandler(); + window.location = redirectUrl; + }; + + const abortPayment = ( onConfirmEvent, message ) => { + onConfirmEvent.paymentFailed( { reason: 'fail' } ); + setExpressPaymentError( message ); + onAbortPaymentHandler(); + }; + + const onButtonClick = useCallback( + ( event ) => { + const options = { + lineItems: normalizeLineItems( billing?.cartTotalItems ), + emailRequired: true, + shippingAddressRequired: shippingData?.needsShipping, + phoneNumberRequired: + getExpressCheckoutData( 'checkout' )?.needs_payer_phone ?? + false, + shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map( + ( r ) => { + return { + id: r.rate_id, + amount: parseInt( r.price, 10 ), + displayName: r.name, + }; + } + ), + allowedShippingCountries: getExpressCheckoutData( 'checkout' ) + .allowed_shipping_countries, + }; + + // Click event from WC Blocks. + onClick(); + // Global click event handler from WooPayments to ECE. + onClickHandler( event ); + event.resolve( options ); + }, + [ + onClick, + billing.cartTotalItems, + shippingData.needsShipping, + shippingData.shippingRates, + ] + ); + + const onConfirm = async ( event ) => { + onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + }; + + return { + buttonOptions, + onButtonClick, + onConfirm, + onReady: onReadyHandler, + onCancel, + elements, + }; +}; diff --git a/client/tokenized-express-checkout/blocks/index.js b/client/tokenized-express-checkout/blocks/index.js new file mode 100644 index 00000000000..d2cefd85eb7 --- /dev/null +++ b/client/tokenized-express-checkout/blocks/index.js @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/constants'; +import { getConfig } from 'wcpay/utils/checkout'; +import ExpressCheckoutContainer from './components/express-checkout-container'; +import { checkPaymentMethodIsAvailable } from '../utils/checkPaymentMethodIsAvailable'; + +export const tokenizedExpressCheckoutElementApplePay = ( api ) => ( { + paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, + name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT + '_applePay', + title: 'WooPayments - Apple Pay', + description: __( + "An easy, secure way to pay that's accepted on millions of stores.", + 'woocommerce-payments' + ), + gatewayId: 'woocommerce_payments', + content: ( + + ), + edit: ( + + ), + supports: { + features: getConfig( 'features' ), + style: [ 'height', 'borderRadius' ], + }, + canMakePayment: ( { cart } ) => { + if ( typeof wcpayExpressCheckoutParams === 'undefined' ) { + return false; + } + + return new Promise( ( resolve ) => { + checkPaymentMethodIsAvailable( 'applePay', cart, resolve ); + } ); + }, +} ); + +export const tokenizedExpressCheckoutElementGooglePay = ( api ) => { + return { + paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, + name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT + '_googlePay', + title: 'WooPayments - Google Pay', + description: __( + 'Simplify checkout with fewer steps to pay.', + 'woocommerce-payments' + ), + gatewayId: 'woocommerce_payments', + content: ( + + ), + edit: ( + + ), + supports: { + features: getConfig( 'features' ), + style: [ 'height', 'borderRadius' ], + }, + canMakePayment: ( { cart } ) => { + if ( typeof wcpayExpressCheckoutParams === 'undefined' ) { + return false; + } + + return new Promise( ( resolve ) => { + checkPaymentMethodIsAvailable( 'googlePay', cart, resolve ); + } ); + }, + }; +}; diff --git a/client/tokenized-express-checkout/button-ui.js b/client/tokenized-express-checkout/button-ui.js new file mode 100644 index 00000000000..9e29ea6b7b5 --- /dev/null +++ b/client/tokenized-express-checkout/button-ui.js @@ -0,0 +1,49 @@ +/* global jQuery */ + +let $expressCheckoutSeparator = null; +let expressCheckoutElementId = null; + +const get$Container = () => jQuery( expressCheckoutElementId ); + +const expressCheckoutButtonUi = { + init: ( { elementId, $separator } ) => { + expressCheckoutElementId = elementId; + $expressCheckoutSeparator = $separator; + }, + + getButtonSeparator: () => { + return $expressCheckoutSeparator; + }, + + blockButton: () => { + // check if element isn't already blocked before calling block() to avoid blinking overlay issues + // blockUI.isBlocked is either undefined or 0 when element is not blocked + if ( get$Container().data( 'blockUI.isBlocked' ) ) { + return; + } + + get$Container().block( { message: null } ); + }, + + unblockButton: () => { + expressCheckoutButtonUi.showContainer(); + get$Container().unblock(); + }, + + renderButton: ( eceButton ) => { + if ( get$Container()?.length ) { + expressCheckoutButtonUi.showContainer(); + eceButton.mount( expressCheckoutElementId ); + } + }, + + hideContainer: () => { + get$Container().hide(); + }, + + showContainer: () => { + get$Container().show(); + }, +}; + +export default expressCheckoutButtonUi; 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 new file mode 100644 index 00000000000..c2d3ad557ef --- /dev/null +++ b/client/tokenized-express-checkout/event-handlers.js @@ -0,0 +1,213 @@ +/* global jQuery */ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +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'; + +let cartApi = new ExpressCheckoutCartApi(); +export const setCartApiHandler = ( handler ) => ( cartApi = handler ); +export const getCartApiHandler = () => cartApi; + +export const shippingAddressChangeHandler = async ( event, elements ) => { + try { + // 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 + ), + } ); + + 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; + } + + 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 ( event, elements ) => { + try { + const cartData = await cartApi.selectShippingRate( { + package_id: 0, + rate_id: event.shippingRate.id, + } ); + + 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(); + } +}; + +export const onConfirmHandler = async ( + api, + stripe, + elements, + completePayment, + abortPayment, + event +) => { + const { error: submitError } = await elements.submit(); + if ( submitError ) { + return abortPayment( event, submitError.message ); + } + + const { paymentMethod, error } = await stripe.createPaymentMethod( { + elements, + } ); + + if ( error ) { + return abortPayment( event, error.message ); + } + + try { + // Kick off checkout processing step. + 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.payment_result.payment_status !== 'success' ) { + return abortPayment( + event, + getErrorMessageFromNotice( + orderResponse.message ?? + orderResponse.payment_result?.payment_details.find( + ( detail ) => detail.key === 'errorMessage' + )?.value ?? + '' + ) + ); + } + + const confirmationRequest = api.confirmIntent( + orderResponse.payment_result.redirect_url + ); + + // `true` means there is no intent to confirm. + if ( confirmationRequest === true ) { + completePayment( orderResponse.payment_result.redirect_url ); + } else { + const redirectUrl = await confirmationRequest; + + completePayment( redirectUrl ); + } + } catch ( e ) { + return abortPayment( + event, + getErrorMessageFromNotice( e.message ) || + e.payment_result?.payment_details.find( + ( detail ) => detail.key === 'errorMessage' + )?.value || + __( + 'There was a problem processing the order.', + 'woocommerce-payments' + ) + ); + } +}; + +export const onReadyHandler = async function ( { availablePaymentMethods } ) { + if ( availablePaymentMethods ) { + const enabledMethods = Object.entries( availablePaymentMethods ) + // eslint-disable-next-line no-unused-vars + .filter( ( [ _, isEnabled ] ) => isEnabled ) + // eslint-disable-next-line no-unused-vars + .map( ( [ methodName, _ ] ) => methodName ); + + trackExpressCheckoutButtonLoad( { + paymentMethods: enabledMethods, + source: getExpressCheckoutData( 'button_context' ), + } ); + } +}; + +const blockUI = () => { + jQuery.blockUI( { + message: null, + overlayCSS: { + background: '#fff', + opacity: 0.6, + }, + } ); +}; + +const unblockUI = () => { + jQuery.unblockUI(); +}; + +export const onClickHandler = async function ( { expressPaymentType } ) { + blockUI(); + trackExpressCheckoutButtonClick( + expressPaymentType, + getExpressCheckoutData( 'button_context' ) + ); +}; + +export const onAbortPaymentHandler = () => { + unblockUI(); +}; + +export const onCompletePaymentHandler = () => { + blockUI(); +}; + +export const onCancelHandler = () => { + unblockUI(); +}; diff --git a/client/tokenized-express-checkout/index.js b/client/tokenized-express-checkout/index.js new file mode 100644 index 00000000000..940aa1462b8 --- /dev/null +++ b/client/tokenized-express-checkout/index.js @@ -0,0 +1,588 @@ +/* global jQuery, wcpayExpressCheckoutParams */ +import { __ } from '@wordpress/i18n'; +import { debounce } from 'lodash'; + +/** + * Internal dependencies + */ +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, + displayLoginConfirmation, +} from './utils'; +import { + onAbortPaymentHandler, + onCancelHandler, + onClickHandler, + onCompletePaymentHandler, + onConfirmHandler, + 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. + if ( + getExpressCheckoutData( 'has_block' ) && + getExpressCheckoutData( 'button_context' ) !== 'pay_for_order' + ) { + return; + } + + const publishableKey = getExpressCheckoutData( 'stripe' ).publishableKey; + const quantityInputSelector = '.quantity .qty[type=number]'; + + if ( ! publishableKey ) { + // If no configuration is present, probably this is not the checkout page. + return; + } + + const api = new WCPayAPI( + { + publishableKey, + accountId: getExpressCheckoutData( 'stripe' ).accountId, + locale: getExpressCheckoutData( 'stripe' ).locale, + }, + // A promise-based interface to jQuery.post. + ( url, args ) => { + return new Promise( ( resolve, reject ) => { + jQuery.post( url, args ).then( resolve ).fail( reject ); + } ); + } + ); + + 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' ), + } ); + + let wcPayECEError = ''; + const defaultErrorMessage = __( + 'There was an error getting the product information.', + 'woocommerce-payments' + ); + + /** + * Object to handle Stripe payment forms. + */ + const wcpayECE = { + getAttributes: function () { + const select = $( '.variations_form' ).find( '.variations select' ); + const data = {}; + let count = 0; + let chosen = 0; + + select.each( function () { + const attributeName = + $( this ).data( 'attribute_name' ) || + $( this ).attr( 'name' ); + const value = $( this ).val() || ''; + + if ( value.length > 0 ) { + chosen++; + } + + count++; + data[ attributeName ] = value; + } ); + + return { + count: count, + chosenCount: chosen, + data: data, + }; + }, + + /** + * Abort the payment and display error messages. + * + * @param {PaymentResponse} payment Payment response instance. + * @param {string} message Error message to display. + */ + abortPayment: ( payment, message ) => { + payment.paymentFailed( { reason: 'fail' } ); + onAbortPaymentHandler(); + + $( '.woocommerce-error' ).remove(); + + const $container = $( '.woocommerce-notices-wrapper' ).first(); + + if ( $container.length ) { + $container.append( + $( '
' ).text( message ) + ); + + $( 'html, body' ).animate( + { + scrollTop: $container + .find( '.woocommerce-error' ) + .offset().top, + }, + 600 + ); + } + }, + + /** + * Complete payment. + * + * @param {string} url Order thank you page URL. + */ + completePayment: ( url ) => { + onCompletePaymentHandler(); + window.location = url; + }, + + /** + * Adds the item to the cart and return cart details. + * + * @return {Promise} Promise for the request to the server. + */ + addToCart: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const data = { + product_id: productId, + qty: $( quantityInputSelector ).val(), + attributes: $( '.variations_form' ).length + ? wcpayECE.getAttributes().data + : [], + }; + + // Add extension data to the POST body + const formData = $( 'form.cart' ).serializeArray(); + $.each( formData, ( i, field ) => { + if ( /^(addon-|wc_)/.test( field.name ) ) { + if ( /\[\]$/.test( field.name ) ) { + const fieldName = field.name.substring( + 0, + field.name.length - 2 + ); + if ( data[ fieldName ] ) { + data[ fieldName ].push( field.value ); + } else { + data[ fieldName ] = [ field.value ]; + } + } else { + data[ field.name ] = field.value; + } + } + } ); + + // TODO ~FR: replace with cartApi + return api.expressCheckoutECEAddToCart( data ); + }, + + /** + * Starts the Express Checkout Element + * + * @param {Object} options ECE options. + */ + 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', + } ); + + const eceButton = elements.create( + 'expressCheckout', + getExpressCheckoutButtonStyleSettings() + ); + + expressCheckoutButtonUi.renderButton( eceButton ); + + eceButton.on( 'loaderror', () => { + wcPayECEError = __( + 'The cart is incompatible with express checkout.', + 'woocommerce-payments' + ); + if ( ! document.getElementById( 'wcpay-woopay-button' ) ) { + expressCheckoutButtonUi.getButtonSeparator().hide(); + } + } ); + + eceButton.on( 'click', function ( event ) { + // If login is required for checkout, display redirect confirmation dialog. + if ( getExpressCheckoutData( 'login_confirmation' ) ) { + displayLoginConfirmation( event.expressPaymentType ); + return; + } + + if ( + getExpressCheckoutData( 'button_context' ) === 'product' + ) { + const addToCartButton = $( '.single_add_to_cart_button' ); + + // First check if product can be added to cart. + if ( addToCartButton.is( '.disabled' ) ) { + if ( + addToCartButton.is( '.wc-variation-is-unavailable' ) + ) { + window.alert( + window?.wc_add_to_cart_variation_params + ?.i18n_unavailable_text || + __( + 'Sorry, this product is unavailable. Please choose a different combination.', + 'woocommerce-payments' + ) + ); + } else { + window.alert( + __( + 'Please select your product options before proceeding.', + 'woocommerce-payments' + ) + ); + } + return; + } + + if ( wcPayECEError ) { + window.alert( wcPayECEError ); + return; + } + + // Add products to the cart if everything is right. + // TODO ~FR: use cartApi + wcpayECE.addToCart(); + } + + const clickOptions = { + lineItems: options.displayItems, + emailRequired: true, + shippingAddressRequired: options.requestShipping, + phoneNumberRequired: options.requestPhone, + shippingRates: options.shippingRates, + allowedShippingCountries: getExpressCheckoutData( + 'checkout' + ).allowed_shipping_countries, + }; + + onClickHandler( event ); + event.resolve( clickOptions ); + } ); + + eceButton.on( 'shippingaddresschange', async ( event ) => + shippingAddressChangeHandler( event, elements ) + ); + + eceButton.on( 'shippingratechange', async ( event ) => + shippingRateChangeHandler( event, elements ) + ); + + eceButton.on( 'confirm', async ( event ) => { + return onConfirmHandler( + api, + stripe, + elements, + wcpayECE.completePayment, + wcpayECE.abortPayment, + event + ); + } ); + + eceButton.on( 'cancel', async () => { + wcpayECE.paymentAborted = true; + onCancelHandler(); + } ); + + eceButton.on( 'ready', ( onReadyParams ) => { + onReadyHandler( onReadyParams ); + + if ( + onReadyParams?.availablePaymentMethods && + Object.values( + onReadyParams.availablePaymentMethods + ).filter( Boolean ).length + ) { + expressCheckoutButtonUi.showContainer(); + expressCheckoutButtonUi.getButtonSeparator().show(); + } + } ); + + if ( getExpressCheckoutData( 'button_context' ) === 'product' ) { + wcpayECE.attachProductPageEventListeners( elements ); + } + }, + + getSelectedProductData: () => { + let productId = $( '.single_add_to_cart_button' ).val(); + + // Check if product is a variable product. + if ( $( '.single_variation_wrap' ).length ) { + productId = $( '.single_variation_wrap' ) + .find( 'input[name="product_id"]' ) + .val(); + } + + if ( $( '.wc-bookings-booking-form' ).length ) { + productId = $( '.wc-booking-product-id' ).val(); + } + + const addons = + $( '#product-addons-total' ).data( 'price_data' ) || []; + const addonValue = addons.reduce( + ( sum, addon ) => sum + addon.cost, + 0 + ); + + // WC Deposits Support. + const depositObject = {}; + if ( $( 'input[name=wc_deposit_option]' ).length ) { + depositObject.wc_deposit_option = $( + 'input[name=wc_deposit_option]:checked' + ).val(); + } + if ( $( 'input[name=wc_deposit_payment_plan]' ).length ) { + depositObject.wc_deposit_payment_plan = $( + 'input[name=wc_deposit_payment_plan]:checked' + ).val(); + } + + const data = { + product_id: productId, + qty: $( quantityInputSelector ).val(), + attributes: $( '.variations_form' ).length + ? wcpayECE.getAttributes().data + : [], + addon_value: addonValue, + ...depositObject, + }; + + // TODO ~FR: replace with cartApi + return api.expressCheckoutECEGetSelectedProductData( data ); + }, + + attachProductPageEventListeners: ( elements ) => { + // WooCommerce Deposits support. + // Trigger the "woocommerce_variation_has_changed" event when the deposit option is changed. + // Needs to be defined before the `woocommerce_variation_has_changed` event handler is set. + $( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ) + .off( 'change' ) + .on( 'change', () => { + $( 'form' ) + .has( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ) + .trigger( 'woocommerce_variation_has_changed' ); + } ); + + $( document.body ) + .off( 'woocommerce_variation_has_changed' ) + .on( 'woocommerce_variation_has_changed', () => { + expressCheckoutButtonUi.blockButton(); + + $.when( wcpayECE.getSelectedProductData() ) + .then( ( response ) => { + // TODO ~FR: this seems new + const isDeposits = wcpayECE.productHasDepositOption(); + /** + * If the customer aborted the express checkout, + * we need to re init the express checkout button to ensure the shipping + * options are refetched. If the customer didn't abort the express checkout, + * and the product's shipping status is consistent, + * we can simply update the express checkout button with the new total and display items. + */ + const needsShipping = + ! wcpayECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === response.needs_shipping; + + if ( ! isDeposits && needsShipping ) { + elements.update( { + amount: response.total.amount, + } ); + } else { + wcpayECE.reInitExpressCheckoutElement( + response + ); + } + } ) + .catch( () => { + expressCheckoutButtonUi.hideContainer(); + expressCheckoutButtonUi.getButtonSeparator().hide(); + } ) + .always( () => { + expressCheckoutButtonUi.unblockButton(); + } ); + } ); + + $( '.quantity' ) + .off( 'input', '.qty' ) + .on( + 'input', + '.qty', + debounce( () => { + expressCheckoutButtonUi.blockButton(); + wcPayECEError = ''; + + $.when( wcpayECE.getSelectedProductData() ) + .then( + ( response ) => { + // In case the server returns an unexpected response + if ( typeof response !== 'object' ) { + wcPayECEError = defaultErrorMessage; + } + + if ( + ! wcpayECE.paymentAborted && + getExpressCheckoutData( 'product' ) + .needs_shipping === + response.needs_shipping + ) { + elements.update( { + amount: response.total.amount, + } ); + } else { + wcpayECE.reInitExpressCheckoutElement( + response + ); + } + }, + ( response ) => { + wcPayECEError = + response.responseJSON?.error ?? + defaultErrorMessage; + } + ) + .always( function () { + expressCheckoutButtonUi.unblockButton(); + } ); + }, 250 ) + ); + }, + + reInitExpressCheckoutElement: ( response ) => { + wcpayExpressCheckoutParams.product.needs_shipping = + response.needs_shipping; + wcpayExpressCheckoutParams.product.total = response.total; + wcpayExpressCheckoutParams.product.displayItems = + response.displayItems; + wcpayECE.init(); + }, + + productHasDepositOption() { + return !! $( 'form' ).has( + 'input[name=wc_deposit_option],input[name=wc_deposit_payment_plan]' + ).length; + }, + + /** + * Initialize event handlers and UI state + */ + init: async () => { + if ( getExpressCheckoutData( 'button_context' ) === 'product' ) { + await wcpayECE.startExpressCheckoutElement( { + mode: 'payment', + total: getExpressCheckoutData( 'product' )?.total.amount, + currency: getExpressCheckoutData( 'product' )?.currency, + requestShipping: + getExpressCheckoutData( 'product' )?.needs_shipping ?? + false, + requestPhone: + getExpressCheckoutData( 'checkout' ) + ?.needs_payer_phone ?? false, + displayItems: getExpressCheckoutData( 'product' ) + .displayItems, + } ); + } else { + // 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. + wcpayECE.paymentAborted = false; + }, + }; + + // We don't need to initialize ECE on the checkout page now because it will be initialized by updated_checkout event. + if ( + getExpressCheckoutData( 'button_context' ) !== 'checkout' || + getExpressCheckoutData( 'button_context' ) === 'pay_for_order' + ) { + wcpayECE.init(); + } + + // We need to refresh ECE data when total is updated. + $( document.body ).on( 'updated_cart_totals', () => { + wcpayECE.init(); + } ); + + // We need to refresh ECE data when total is updated. + $( document.body ).on( 'updated_checkout', () => { + wcpayECE.init(); + } ); +} ); diff --git a/client/tokenized-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/tracking.js b/client/tokenized-express-checkout/tracking.js new file mode 100644 index 00000000000..b7bfd800a17 --- /dev/null +++ b/client/tokenized-express-checkout/tracking.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { debounce } from 'lodash'; +import { recordUserEvent } from 'tracks'; + +// Track the button click event. +export const trackExpressCheckoutButtonClick = ( paymentMethod, source ) => { + const expressPaymentTypeEvents = { + google_pay: 'gpay_button_click', + apple_pay: 'applepay_button_click', + }; + + const event = expressPaymentTypeEvents[ paymentMethod ]; + if ( ! event ) return; + + recordUserEvent( event, { source } ); +}; + +const debouncedTrackApplePayButtonLoad = debounce( ( { source } ) => { + recordUserEvent( 'applepay_button_load', { source } ); +}, 1000 ); + +const debouncedTrackGooglePayButtonLoad = debounce( ( { source } ) => { + recordUserEvent( 'gpay_button_load', { source } ); +}, 1000 ); + +// Track the button load event. +export const trackExpressCheckoutButtonLoad = ( { + paymentMethods, + source, +} ) => { + const expressPaymentTypeEvents = { + googlePay: debouncedTrackGooglePayButtonLoad, + applePay: debouncedTrackApplePayButtonLoad, + }; + + for ( const paymentMethod of paymentMethods ) { + const debouncedTrackFunction = + expressPaymentTypeEvents[ paymentMethod ]; + if ( ! debouncedTrackFunction ) continue; + + debouncedTrackFunction( { source } ); + } +}; 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-express-checkout/utils/checkPaymentMethodIsAvailable.js b/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js new file mode 100644 index 00000000000..b592169da22 --- /dev/null +++ b/client/tokenized-express-checkout/utils/checkPaymentMethodIsAvailable.js @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import ReactDOM from 'react-dom'; +import { ExpressCheckoutElement, Elements } from '@stripe/react-stripe-js'; +import { memoize } from 'lodash'; + +/** + * Internal dependencies + */ +import { isLinkEnabled } from 'wcpay/checkout/utils/upe'; +import request from 'wcpay/checkout/utils/request'; +import WCPayAPI from 'wcpay/checkout/api'; +import { getUPEConfig } from 'wcpay/utils/checkout'; + +export const checkPaymentMethodIsAvailable = memoize( + ( paymentMethod, cart, resolve ) => { + // Create the DIV container on the fly + const containerEl = document.createElement( 'div' ); + + // Ensure the element is hidden and doesn’t interfere with the page layout. + containerEl.style.display = 'none'; + + document.querySelector( 'body' ).appendChild( containerEl ); + + const root = ReactDOM.createRoot( containerEl ); + + const api = new WCPayAPI( + { + publishableKey: getUPEConfig( 'publishableKey' ), + accountId: getUPEConfig( 'accountId' ), + forceNetworkSavedCards: getUPEConfig( + 'forceNetworkSavedCards' + ), + locale: getUPEConfig( 'locale' ), + isStripeLinkEnabled: isLinkEnabled( + getUPEConfig( 'paymentMethodsConfig' ) + ), + }, + request + ); + + root.render( + + resolve( false ) } + options={ { + paymentMethods: { + amazonPay: 'never', + applePay: + paymentMethod === 'applePay' + ? 'always' + : 'never', + googlePay: + paymentMethod === 'googlePay' + ? 'always' + : 'never', + link: 'never', + paypal: 'never', + }, + } } + onReady={ ( event ) => { + let canMakePayment = false; + if ( event.availablePaymentMethods ) { + canMakePayment = + event.availablePaymentMethods[ paymentMethod ]; + } + resolve( canMakePayment ); + root.unmount(); + containerEl.remove(); + } } + /> + + ); + } +); diff --git a/client/tokenized-express-checkout/utils/index.ts b/client/tokenized-express-checkout/utils/index.ts new file mode 100644 index 00000000000..9b92ec023ba --- /dev/null +++ b/client/tokenized-express-checkout/utils/index.ts @@ -0,0 +1,176 @@ +/** + * Internal dependencies + */ +import { WCPayExpressCheckoutParams } from 'wcpay/express-checkout/utils'; +export * from './normalize'; +import { getDefaultBorderRadius } from 'wcpay/utils/express-checkout'; + +export const getExpressCheckoutData = < + K extends keyof WCPayExpressCheckoutParams +>( + key: K +) => { + if ( typeof window.wcpayExpressCheckoutParams !== 'undefined' ) { + return window.wcpayExpressCheckoutParams[ key ] ?? null; + } + + if ( typeof window.wc?.wcSettings !== 'undefined' ) { + return window.wc.wcSettings.getSetting( 'ece_data' )?.[ key ] ?? null; + } + + return null; +}; + +/** + * Get error messages from WooCommerce notice from server response. + * + * @param notice Error notice. + * @return Error messages. + */ +export const getErrorMessageFromNotice = ( notice: string ) => { + const div = document.createElement( 'div' ); + div.innerHTML = notice.trim(); + return div.firstChild ? div.firstChild.textContent : ''; +}; + +type ExpressPaymentType = + | 'apple_pay' + | 'google_pay' + | 'amazon_pay' + | 'paypal' + | 'link'; + +/** + * Displays a `confirm` dialog which leads to a redirect. + * + * @param expressPaymentType Can be either 'apple_pay', 'google_pay', 'amazon_pay', 'paypal' or 'link'. + */ +export const displayLoginConfirmation = ( + expressPaymentType: ExpressPaymentType +) => { + const loginConfirmation = getExpressCheckoutData( 'login_confirmation' ); + + if ( ! loginConfirmation ) { + return; + } + + const paymentTypesMap = { + apple_pay: 'Apple Pay', + google_pay: 'Google Pay', + amazon_pay: 'Amazon Pay', + paypal: 'PayPal', + link: 'Link', + }; + let message = loginConfirmation.message; + + // Replace dialog text with specific express checkout type. + message = message.replace( + /\*\*.*?\*\*/, + paymentTypesMap[ expressPaymentType ] + ); + + // Remove asterisks from string. + message = message.replace( /\*\*/g, '' ); + + if ( confirm( message ) ) { + // Redirect to my account page. + window.location.href = loginConfirmation.redirect_url; + } +}; + +type ButtonAttributesType = + | { height: string; borderRadius: string } + | undefined; + +/** + * Returns the appearance settings for the Express Checkout buttons. + * Currently only configures border radius for the buttons. + */ +export const getExpressCheckoutButtonAppearance = ( + buttonAttributes: ButtonAttributesType +) => { + let borderRadius = getDefaultBorderRadius(); + const buttonSettings = getExpressCheckoutData( 'button' ); + + // Border radius from WooPayments settings + borderRadius = buttonSettings?.radius ?? borderRadius; + + // Border radius from Cart & Checkout blocks attributes + if ( typeof buttonAttributes !== 'undefined' ) { + borderRadius = Number( buttonAttributes?.borderRadius ) ?? borderRadius; + } + + return { + variables: { + borderRadius: `${ borderRadius }px`, + spacingUnit: '6px', + }, + }; +}; + +/** + * Returns the style settings for the Express Checkout buttons. + */ +export const getExpressCheckoutButtonStyleSettings = () => { + const buttonSettings = getExpressCheckoutData( 'button' ); + + const mapWooPaymentsThemeToButtonTheme = ( + buttonType: string, + theme: string + ) => { + switch ( theme ) { + case 'dark': + return 'black'; + case 'light': + return 'white'; + case 'light-outline': + if ( buttonType === 'googlePay' ) { + return 'white'; + } + + return 'white-outline'; + default: + return 'black'; + } + }; + + const googlePayType = + buttonSettings?.type === 'default' + ? 'plain' + : buttonSettings?.type ?? 'buy'; + + const applePayType = + buttonSettings?.type === 'default' + ? 'plain' + : buttonSettings?.type ?? 'plain'; + + return { + paymentMethods: { + applePay: 'always', + googlePay: 'always', + link: 'never', + paypal: 'never', + amazonPay: 'never', + }, + layout: { overflow: 'never' }, + buttonTheme: { + googlePay: mapWooPaymentsThemeToButtonTheme( + 'googlePay', + buttonSettings?.theme ?? 'black' + ), + applePay: mapWooPaymentsThemeToButtonTheme( + 'applePay', + buttonSettings?.theme ?? 'black' + ), + }, + buttonType: { + googlePay: googlePayType, + applePay: applePayType, + }, + // Allowed height must be 40px to 55px. + buttonHeight: Math.min( + Math.max( parseInt( buttonSettings?.height ?? '48', 10 ), 40 ), + 55 + ), + }; +}; diff --git a/client/tokenized-express-checkout/utils/normalize.js b/client/tokenized-express-checkout/utils/normalize.js new file mode 100644 index 00000000000..e55a44bfee9 --- /dev/null +++ b/client/tokenized-express-checkout/utils/normalize.js @@ -0,0 +1,123 @@ +/** + * Normalizes incoming cart total items for use as a displayItems with the Stripe api. + * + * @param {Array} displayItems Items to normalize. + * @param {boolean} pending Whether to mark items as pending or not. + * + * @return {Array} An array of PaymentItems + */ +export const normalizeLineItems = ( displayItems ) => { + return displayItems.map( ( displayItem ) => { + let amount = displayItem?.amount ?? displayItem?.value; + if ( displayItem.key === 'total_discount' ) { + amount = -amount; + } + + return { + name: displayItem.label, + amount, + }; + } ); +}; + +/** + * Normalize order data from Stripe's object to the expected format for WC. + * + * @param {Object} event Stripe's event object. + * @param {string} paymentMethodId Stripe's payment method id. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const normalizeOrderData = ( event, paymentMethodId ) => { + const name = event?.billingDetails?.name; + const email = event?.billingDetails?.email ?? ''; + const billing = event?.billingDetails?.address ?? {}; + const shipping = event?.shippingAddress ?? {}; + const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; + + const phone = + event?.billingDetails?.phone?.replace( /[() -]/g, '' ) ?? + event?.payerPhone?.replace( /[() -]/g, '' ) ?? + ''; + + return { + billing_first_name: + name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '-', + billing_company: billing?.organization ?? '', + billing_email: email ?? event?.payerEmail ?? '', + billing_phone: phone, + billing_country: billing?.country ?? '', + billing_address_1: billing?.line1 ?? '', + billing_address_2: billing?.line2 ?? '', + billing_city: billing?.city ?? '', + billing_state: billing?.state ?? '', + billing_postcode: billing?.postal_code ?? '', + shipping_first_name: + shipping?.name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + shipping_last_name: + shipping?.name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '', + shipping_company: shipping?.organization ?? '', + shipping_phone: phone, + shipping_country: shipping?.address?.country ?? '', + shipping_address_1: shipping?.address?.line1 ?? '', + shipping_address_2: shipping?.address?.line2 ?? '', + shipping_city: shipping?.address?.city ?? '', + shipping_state: shipping?.address?.state ?? '', + shipping_postcode: shipping?.address?.postal_code ?? '', + shipping_method: [ event?.shippingRate?.id ?? null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: event?.expressPaymentType, + express_payment_type: event?.expressPaymentType, + 'wcpay-fraud-prevention-token': fraudPreventionTokenValue, + }; +}; + +/** + * Normalize Pay for Order data from Stripe's object to the expected format for WC. + * + * @param {Object} event Stripe's event object. + * @param {string} paymentMethodId Stripe's payment method id. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const normalizePayForOrderData = ( event, paymentMethodId ) => { + return { + payment_method: 'woocommerce_payments', + 'wcpay-payment-method': paymentMethodId, + express_payment_type: event?.expressPaymentType, + 'wcpay-fraud-prevention-token': window.wcpayFraudPreventionToken ?? '', + }; +}; + +/** + * Normalize shipping address information from Stripe's address object to + * the cart shipping address object shape. + * + * @param {Object} shippingAddress Stripe's shipping address item + * + * @return {Object} The shipping address in the shape expected by the cart. + */ +export const normalizeShippingAddress = ( shippingAddress ) => { + return { + first_name: + shippingAddress?.recipient + ?.split( ' ' ) + ?.slice( 0, 1 ) + ?.join( ' ' ) ?? '', + last_name: + shippingAddress?.recipient?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? + '', + company: '', + address_1: shippingAddress?.addressLine?.[ 0 ] ?? '', + address_2: shippingAddress?.addressLine?.[ 1 ] ?? '', + city: shippingAddress?.city ?? '', + state: shippingAddress?.state ?? '', + country: shippingAddress?.country ?? '', + postcode: shippingAddress?.postal_code ?? '', + }; +}; diff --git a/client/tokenized-express-checkout/utils/test/index.ts b/client/tokenized-express-checkout/utils/test/index.ts new file mode 100644 index 00000000000..df11f86d1d2 --- /dev/null +++ b/client/tokenized-express-checkout/utils/test/index.ts @@ -0,0 +1,41 @@ +/** + * Internal dependencies + */ +import { WCPayExpressCheckoutParams } from 'wcpay/express-checkout/utils'; +import { getErrorMessageFromNotice, getExpressCheckoutData } from '..'; + +describe( 'Express checkout utils', () => { + test( 'getExpressCheckoutData returns null for missing option', () => { + expect( + getExpressCheckoutData( + // Force wrong usage, just in case this is called from JS with incorrect params. + 'does-not-exist' as keyof WCPayExpressCheckoutParams + ) + ).toBeNull(); + } ); + + test( 'getExpressCheckoutData returns correct value for present option', () => { + // We don't care that the implementation is partial for the purposes of the test, so + // the type assertion is fine. + window.wcpayExpressCheckoutParams = { + ajax_url: 'test', + } as WCPayExpressCheckoutParams; + + expect( getExpressCheckoutData( 'ajax_url' ) ).toBe( 'test' ); + } ); + + test( 'getErrorMessageFromNotice strips formatting', () => { + const notice = '

Error: Payment failed.

'; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.' + ); + } ); + + test( 'getErrorMessageFromNotice strips scripts', () => { + const notice = + '

Error: Payment failed.

'; + expect( getErrorMessageFromNotice( notice ) ).toBe( + 'Error: Payment failed.alert("hello")' + ); + } ); +} ); diff --git a/client/tokenized-express-checkout/utils/test/normalize.js b/client/tokenized-express-checkout/utils/test/normalize.js new file mode 100644 index 00000000000..e963a356911 --- /dev/null +++ b/client/tokenized-express-checkout/utils/test/normalize.js @@ -0,0 +1,510 @@ +/** + * Internal dependencies + */ +import { + normalizeLineItems, + normalizeOrderData, + normalizePayForOrderData, + normalizeShippingAddress, +} from '../normalize'; + +describe( 'Express checkout normalization', () => { + describe( 'normalizeLineItems', () => { + test( 'normalizes blocks array properly', () => { + const displayItems = [ + { + label: 'Item 1', + value: 100, + }, + { + label: 'Item 2', + value: 200, + }, + { + label: 'Item 3', + valueWithTax: 300, + value: 200, + }, + ]; + + // Extra items in the array are expected since they're not stripped. + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 200, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + + test( 'normalizes shortcode array properly', () => { + const displayItems = [ + { + label: 'Item 1', + amount: 100, + }, + { + label: 'Item 2', + amount: 200, + }, + { + label: 'Item 3', + amount: 300, + }, + ]; + + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 300, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + + test( 'normalizes discount line item properly', () => { + const displayItems = [ + { + label: 'Item 1', + amount: 100, + }, + { + label: 'Item 2', + amount: 200, + }, + { + label: 'Item 3', + amount: 300, + }, + { + key: 'total_discount', + label: 'Discount', + amount: 50, + }, + ]; + + const expected = [ + { + name: 'Item 1', + amount: 100, + }, + { + name: 'Item 2', + amount: 200, + }, + { + name: 'Item 3', + amount: 300, + }, + { + name: 'Discount', + amount: -50, + }, + ]; + + expect( normalizeLineItems( displayItems ) ).toStrictEqual( + expected + ); + } ); + } ); + + describe( 'normalizeOrderData', () => { + afterEach( () => { + // Clear any changes to the fraud prevention token. + delete window.wcpayFraudPreventionToken; + } ); + + test( 'should normalize order data with complete event and paymentMethodId', () => { + window.wcpayFraudPreventionToken = 'token123'; + + const event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: 'Doe', + billing_company: 'Some Company', + billing_email: 'john.doe@example.com', + billing_phone: '1234567890', + billing_country: 'US', + billing_address_1: '123 Main St', + billing_address_2: 'Apt 4B', + billing_city: 'New York', + billing_state: 'NY', + billing_postcode: '10001', + shipping_first_name: 'John', + shipping_last_name: 'Doe', + shipping_company: 'Some Company', + shipping_phone: '1234567890', + shipping_country: 'US', + shipping_address_1: '123 Main St', + shipping_address_2: 'Apt 4B', + shipping_city: 'New York', + shipping_state: 'NY', + shipping_postcode: '10001', + shipping_method: [ 'rate_1' ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: 'express', + express_payment_type: 'express', + 'wcpay-fraud-prevention-token': 'token123', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with missing optional event fields', () => { + const event = {}; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: '', + billing_last_name: '-', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: undefined, + express_payment_type: undefined, + 'wcpay-fraud-prevention-token': '', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + + test( 'should normalize order data with minimum required fields', () => { + const event = { + billingDetails: { + name: 'John', + }, + }; + const paymentMethodId = 'pm_123456'; + + const expectedNormalizedData = { + billing_first_name: 'John', + billing_last_name: '', + billing_company: '', + billing_email: '', + billing_phone: '', + billing_country: '', + billing_address_1: '', + billing_address_2: '', + billing_city: '', + billing_state: '', + billing_postcode: '', + shipping_first_name: '', + shipping_last_name: '', + shipping_company: '', + shipping_phone: '', + shipping_country: '', + shipping_address_1: '', + shipping_address_2: '', + shipping_city: '', + shipping_state: '', + shipping_postcode: '', + shipping_method: [ null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: undefined, + express_payment_type: undefined, + 'wcpay-fraud-prevention-token': '', + }; + + expect( normalizeOrderData( event, paymentMethodId ) ).toEqual( + expectedNormalizedData + ); + } ); + } ); + + describe( 'normalizePayForOrderData', () => { + test( 'should normalize pay for order data with complete event and paymentMethodId', () => { + window.wcpayFraudPreventionToken = 'token123'; + + const event = { + billingDetails: { + name: 'John Doe', + email: 'john.doe@example.com', + address: { + organization: 'Some Company', + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + phone: '(123) 456-7890', + }, + shippingAddress: { + name: 'John Doe', + organization: 'Some Company', + address: { + country: 'US', + line1: '123 Main St', + line2: 'Apt 4B', + city: 'New York', + state: 'NY', + postal_code: '10001', + }, + }, + shippingRate: { id: 'rate_1' }, + expressPaymentType: 'express', + }; + + expect( normalizePayForOrderData( event, 'pm_123456' ) ).toEqual( { + payment_method: 'woocommerce_payments', + 'wcpay-payment-method': 'pm_123456', + 'wcpay-fraud-prevention-token': 'token123', + express_payment_type: 'express', + } ); + } ); + + test( 'should normalize pay for order data with empty event and empty payment method', () => { + const event = {}; + const paymentMethodId = ''; + + expect( + normalizePayForOrderData( event, paymentMethodId ) + ).toEqual( { + payment_method: 'woocommerce_payments', + 'wcpay-payment-method': '', + 'wcpay-fraud-prevention-token': 'token123', + express_payment_type: undefined, + } ); + } ); + } ); + + describe( 'normalizeShippingAddress', () => { + test( 'should normalize shipping address with all fields present', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with only recipient name', () => { + const shippingAddress = { + recipient: 'John', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with missing recipient name', () => { + const shippingAddress = { + addressLine: [ '123 Main St' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '123 Main St', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize shipping address with empty addressLine', () => { + const shippingAddress = { + recipient: 'John Doe', + addressLine: [], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe', + company: '', + address_1: '', + address_2: '', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize an empty shipping address', () => { + const shippingAddress = {}; + + const expectedNormalizedAddress = { + first_name: '', + last_name: '', + company: '', + address_1: '', + address_2: '', + city: '', + state: '', + country: '', + postcode: '', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + + test( 'should normalize a shipping address with a multi-word recipient name', () => { + const shippingAddress = { + recipient: 'John Doe Smith', + addressLine: [ '123 Main St', 'Apt 4B' ], + city: 'New York', + state: 'NY', + country: 'US', + postal_code: '10001', + }; + + const expectedNormalizedAddress = { + first_name: 'John', + last_name: 'Doe Smith', + company: '', + address_1: '123 Main St', + address_2: 'Apt 4B', + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + }; + + expect( normalizeShippingAddress( shippingAddress ) ).toEqual( + expectedNormalizedAddress + ); + } ); + } ); +} ); diff --git a/client/tokenized-payment-request/README.md b/client/tokenized-payment-request/README.md new file mode 100644 index 00000000000..2c92ba8d2ff --- /dev/null +++ b/client/tokenized-payment-request/README.md @@ -0,0 +1,4 @@ +# Tokenized Payment Request Button + +This directory contains the JS work done by the Heisenberg team to convert the PRBs to leverage the Store API. +We'll delete the directory and its contents as part of https://github.com/Automattic/woocommerce-payments/issues/9722 . diff --git a/client/tokenized-payment-request/blocks/index.js b/client/tokenized-payment-request/blocks/index.js index ebca67f1b7d..f6cb3461102 100644 --- a/client/tokenized-payment-request/blocks/index.js +++ b/client/tokenized-payment-request/blocks/index.js @@ -36,10 +36,7 @@ const tokenizedCartPaymentRequestPaymentMethod = ( api ) => ( { return false; } - if ( - typeof wcpayConfig !== 'undefined' && - wcpayConfig.isExpressCheckoutElementEnabled - ) { + if ( typeof wcpayConfig !== 'undefined' ) { return false; } diff --git a/client/tokenized-payment-request/button-ui.js b/client/tokenized-payment-request/button-ui.js index dc8b4a0f978..b0ca818d213 100644 --- a/client/tokenized-payment-request/button-ui.js +++ b/client/tokenized-payment-request/button-ui.js @@ -9,7 +9,7 @@ const paymentRequestButtonUi = { getElements: () => { return jQuery( - '.wcpay-payment-request-wrapper,#wcpay-payment-request-button-separator' + '.wcpay-express-checkout-wrapper,#wcpay-express-checkout-button-separator' ); }, 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/tracks/index.ts b/client/tracks/index.ts index 5fd10f9c5e8..a470678481d 100644 --- a/client/tracks/index.ts +++ b/client/tracks/index.ts @@ -8,7 +8,7 @@ import domReady from '@wordpress/dom-ready'; */ import { Event } from './event'; import { getConfig } from 'wcpay/utils/checkout'; -import { getPaymentRequestData } from 'wcpay/utils/express-checkout'; +import { getExpressCheckoutConfig } from 'wcpay/utils/express-checkout'; /** * Checks if site tracking is enabled. @@ -69,9 +69,9 @@ export const recordUserEvent = ( ): void => { const nonce = getConfig( 'platformTrackerNonce' ) ?? - getPaymentRequestData( 'nonce' )?.platform_tracker; + getExpressCheckoutConfig( 'nonce' )?.platform_tracker; const ajaxUrl = - getConfig( 'ajaxUrl' ) ?? getPaymentRequestData( 'ajax_url' ); + getConfig( 'ajaxUrl' ) ?? getExpressCheckoutConfig( 'ajax_url' ); const body = new FormData(); body.append( 'tracksNonce', nonce ); @@ -118,9 +118,9 @@ export const getTracksIdentity = async (): Promise< string | undefined > => { // Otherwise get it via an Ajax request. const nonce = getConfig( 'platformTrackerNonce' ) ?? - getPaymentRequestData( 'nonce' )?.platform_tracker; + getExpressCheckoutConfig( 'nonce' )?.platform_tracker; const ajaxUrl = - getConfig( 'ajaxUrl' ) ?? getPaymentRequestData( 'ajax_url' ); + getConfig( 'ajaxUrl' ) ?? getExpressCheckoutConfig( 'ajax_url' ); const body = new FormData(); body.append( 'tracksNonce', nonce ); 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/client/utils/express-checkout/index.js b/client/utils/express-checkout/index.js index 8a9e7ce68f0..80d185da585 100644 --- a/client/utils/express-checkout/index.js +++ b/client/utils/express-checkout/index.js @@ -46,23 +46,6 @@ export const getExpressCheckoutAjaxURL = ( endpoint ) => .toString() .replace( '%%endpoint%%', 'wcpay_' + endpoint ); -/** - * Retrieves payment request data from global variable. - * - * @param {string} key The object property key. - * @return {mixed} Value of the object prop or null. - */ -export const getPaymentRequestData = ( key ) => getExpressCheckoutConfig( key ); - -/** - * Get WC AJAX endpoint URL. - * - * @param {string} endpoint Endpoint. - * @return {string} URL with interpolated endpoint. - */ -export const getPaymentRequestAjaxURL = ( endpoint ) => - getExpressCheckoutAjaxURL( endpoint ); - /** * Construct WC AJAX endpoint URL. * @@ -73,31 +56,3 @@ export const getPaymentRequestAjaxURL = ( endpoint ) => */ export const buildAjaxURL = ( ajaxURL, endpoint, prefix = 'wcpay_' ) => ajaxURL.toString().replace( '%%endpoint%%', prefix + endpoint ); - -/** - * Whether or not to use Google Pay branded button in Chrome. - * - * @return {boolean} Use Google Pay button in Chrome. - */ -export const shouldUseGooglePayBrand = () => { - const ua = window.navigator.userAgent.toLowerCase(); - const isChrome = - /chrome/.test( ua ) && - ! /edge|edg|opr|brave\//.test( ua ) && - window.navigator.vendor === 'Google Inc.'; - // newer versions of Brave do not have the userAgent string - const isBrave = isChrome && window.navigator.brave; - return isChrome && ! isBrave; -}; - -/** - * Get error messages from WooCommerce notice from server response. - * - * @param {string} notice Error notice. - * @return {string} Error messages. - */ -export const getErrorMessageFromNotice = ( notice ) => { - const div = document.createElement( 'div' ); - div.innerHTML = notice.trim(); - return div.firstChild ? div.firstChild.textContent : ''; -}; 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/admin/class-wc-rest-payments-settings-controller.php b/includes/admin/class-wc-rest-payments-settings-controller.php index dbdc4abd522..012604733b9 100644 --- a/includes/admin/class-wc-rest-payments-settings-controller.php +++ b/includes/admin/class-wc-rest-payments-settings-controller.php @@ -510,7 +510,7 @@ public function get_settings(): WP_REST_Response { 'payment_request_button_size' => $this->wcpay_gateway->get_option( 'payment_request_button_size' ), 'payment_request_button_type' => $this->wcpay_gateway->get_option( 'payment_request_button_type' ), 'payment_request_button_theme' => $this->wcpay_gateway->get_option( 'payment_request_button_theme' ), - 'payment_request_button_border_radius' => WC_Payments_Features::is_stripe_ece_enabled() ? $this->wcpay_gateway->get_option( 'payment_request_button_border_radius', WC_Payments_Express_Checkout_Button_Handler::DEFAULT_BORDER_RADIUS_IN_PX ) : WC_Payments_Express_Checkout_Button_Handler::DEFAULT_BORDER_RADIUS_IN_PX, + 'payment_request_button_border_radius' => $this->wcpay_gateway->get_option( 'payment_request_button_border_radius', WC_Payments_Express_Checkout_Button_Handler::DEFAULT_BORDER_RADIUS_IN_PX ), 'is_saved_cards_enabled' => $this->wcpay_gateway->is_saved_cards_enabled(), 'is_card_present_eligible' => $this->wcpay_gateway->is_card_present_eligible() && isset( WC()->payment_gateways()->get_available_payment_gateways()['cod'] ), 'is_woopay_enabled' => 'yes' === $this->wcpay_gateway->get_option( 'platform_checkout' ), diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index a956a141cef..e68cc4469d7 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1850,8 +1850,11 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $payment_method_details = $charge ? $charge->get_payment_method_details() : []; $payment_method_type = $payment_method_details ? $payment_method_details['type'] : null; - if ( $order->get_meta( 'is_woopay' ) && 'card' === $payment_method_type && isset( $payment_method_details['card']['last4'] ) ) { + if ( 'card' === $payment_method_type && isset( $payment_method_details['card']['last4'] ) ) { $order->add_meta_data( 'last4', $payment_method_details['card']['last4'], true ); + if ( isset( $payment_method_details['card']['brand'] ) ) { + $order->add_meta_data( '_card_brand', $payment_method_details['card']['brand'], true ); + } $order->save_meta_data(); } } else { @@ -4293,20 +4296,22 @@ public function is_enabled_for_saved_payments( $payment_method_id ) { * @return array WooCommerce checkout fields. */ public function checkout_update_email_field_priority( $fields ) { - $is_link_enabled = in_array( - Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, - \WC_Payments::get_gateway()->get_payment_method_ids_enabled_at_checkout_filtered_by_fees( null, true ), - true - ); + if ( is_checkout() || has_block( 'woocommerce/checkout' ) ) { + $is_link_enabled = in_array( + Link_Payment_Method::PAYMENT_METHOD_STRIPE_ID, + \WC_Payments::get_gateway()->get_payment_method_ids_enabled_at_checkout_filtered_by_fees( null, true ), + true + ); - if ( $is_link_enabled && isset( $fields['billing_email'] ) ) { - // Update the field priority. - $fields['billing_email']['priority'] = 1; + if ( $is_link_enabled && isset( $fields['billing_email'] ) ) { + // Update the field priority. + $fields['billing_email']['priority'] = 1; - // Add extra `wcpay-checkout-email-field` class. - $fields['billing_email']['class'][] = 'wcpay-checkout-email-field'; + // Add extra `wcpay-checkout-email-field` class. + $fields['billing_email']['class'][] = 'wcpay-checkout-email-field'; - add_filter( 'woocommerce_form_field_email', [ $this, 'append_stripelink_button' ], 10, 4 ); + add_filter( 'woocommerce_form_field_email', [ $this, 'append_stripelink_button' ], 10, 4 ); + } } return $fields; 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-blocks-payment-method.php b/includes/class-wc-payments-blocks-payment-method.php index 877d4bf4270..92184696f31 100644 --- a/includes/class-wc-payments-blocks-payment-method.php +++ b/includes/class-wc-payments-blocks-payment-method.php @@ -71,13 +71,7 @@ public function get_payment_method_script_handles() { true ); - $script_dependencies = [ 'stripe' ]; - - if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() && ( is_cart() || is_checkout() || is_product() || has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) ) ) { - $script_dependencies[] = 'WCPAY_PAYMENT_REQUEST'; - } - - WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', $script_dependencies ); + WC_Payments::register_script_with_dependencies( 'WCPAY_BLOCKS_CHECKOUT', 'dist/blocks-checkout', [ 'stripe' ] ); wp_set_script_translations( 'WCPAY_BLOCKS_CHECKOUT', 'woocommerce-payments' ); diff --git a/includes/class-wc-payments-checkout.php b/includes/class-wc-payments-checkout.php index bf8f623897b..7dd49c98b67 100644 --- a/includes/class-wc-payments-checkout.php +++ b/includes/class-wc-payments-checkout.php @@ -190,9 +190,8 @@ public function get_payment_fields_js_config() { 'locale' => WC_Payments_Utils::convert_to_stripe_locale( get_locale() ), 'isPreview' => is_preview(), 'isSavedCardsEnabled' => $this->gateway->is_saved_cards_enabled(), - 'isExpressCheckoutElementEnabled' => WC_Payments_Features::is_stripe_ece_enabled(), 'isPaymentRequestEnabled' => $this->gateway->is_payment_request_enabled(), - 'isTokenizedCartPrbEnabled' => WC_Payments_Features::is_tokenized_cart_prb_enabled(), + 'isTokenizedCartEceEnabled' => WC_Payments_Features::is_tokenized_cart_ece_enabled(), 'isWooPayEnabled' => $this->woopay_util->should_enable_woopay( $this->gateway ) && $this->woopay_util->should_enable_woopay_on_cart_or_checkout(), 'isWoopayExpressCheckoutEnabled' => $this->woopay_util->is_woopay_express_checkout_enabled(), 'isWoopayFirstPartyAuthEnabled' => $this->woopay_util->is_woopay_first_party_auth_enabled(), @@ -238,7 +237,9 @@ public function get_payment_fields_js_config() { $enabled_billing_fields = []; foreach ( WC()->checkout()->get_checkout_fields( 'billing' ) as $billing_field => $billing_field_options ) { if ( ! isset( $billing_field_options['enabled'] ) || $billing_field_options['enabled'] ) { - $enabled_billing_fields[] = $billing_field; + $enabled_billing_fields[ $billing_field ] = [ + 'required' => $billing_field_options['required'], + ]; } } $payment_fields['enabledBillingFields'] = $enabled_billing_fields; diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index e80cf6675f1..fa1748f5311 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -23,13 +23,12 @@ class WC_Payments_Features { */ const WCPAY_SUBSCRIPTIONS_FLAG_NAME = '_wcpay_feature_subscriptions'; const STRIPE_BILLING_FLAG_NAME = '_wcpay_feature_stripe_billing'; - const STRIPE_ECE_FLAG_NAME = '_wcpay_feature_stripe_ece'; const WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_express_checkout'; const WOOPAY_FIRST_PARTY_AUTH_FLAG_NAME = '_wcpay_feature_woopay_first_party_auth'; const WOOPAY_DIRECT_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_direct_checkout'; const AUTH_AND_CAPTURE_FLAG_NAME = '_wcpay_feature_auth_and_capture'; const DISPUTE_ISSUER_EVIDENCE = '_wcpay_feature_dispute_issuer_evidence'; - const TOKENIZED_CART_PRB_FLAG_NAME = '_wcpay_feature_tokenized_cart_prb'; + const TOKENIZED_CART_ECE_FLAG_NAME = '_wcpay_feature_tokenized_cart_ece'; const PAYMENT_OVERVIEW_WIDGET_FLAG_NAME = '_wcpay_feature_payment_overview_widget'; const WOOPAY_GLOBAL_THEME_SUPPORT_FLAG_NAME = '_wcpay_feature_woopay_global_theme_support'; @@ -49,8 +48,8 @@ public static function are_payments_enabled() { * * @return bool */ - public static function is_tokenized_cart_prb_enabled(): bool { - return '1' === get_option( self::TOKENIZED_CART_PRB_FLAG_NAME, '0' ); + public static function is_tokenized_cart_ece_enabled(): bool { + return '1' === get_option( self::TOKENIZED_CART_ECE_FLAG_NAME, '0' ); } /** @@ -353,15 +352,6 @@ public static function should_use_stripe_billing() { return false; } - /** - * Checks whether the Stripe Express Checkout Element feature is enabled. - * - * @return bool - */ - public static function is_stripe_ece_enabled(): bool { - return '1' === get_option( self::STRIPE_ECE_FLAG_NAME, '1' ); - } - /** * Checks whether Dispute issuer evidence feature should be enabled. Disabled by default. * @@ -395,7 +385,6 @@ public static function to_array() { 'isAuthAndCaptureEnabled' => self::is_auth_and_capture_enabled(), 'isDisputeIssuerEvidenceEnabled' => self::is_dispute_issuer_evidence_enabled(), 'isPaymentOverviewWidgetEnabled' => self::is_payment_overview_widget_ui_enabled(), - 'isStripeEceEnabled' => self::is_stripe_ece_enabled(), ] ); } 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-order-success-page.php b/includes/class-wc-payments-order-success-page.php index 78b9d6fc12a..26f3791dda6 100644 --- a/includes/class-wc-payments-order-success-page.php +++ b/includes/class-wc-payments-order-success-page.php @@ -81,7 +81,7 @@ public function show_woocommerce_payments_payment_method_name( $payment_method_t $payment_method = $gateway->get_payment_method( $order ); // GooglePay/ApplePay/Link/Card to be supported later. if ( $payment_method->get_id() === Payment_Method::CARD ) { - return $payment_method_title; + return $this->show_card_payment_method_name( $order, $payment_method ); } // If this is an LPM (BNPL or local payment method) order, return the html for the payment method name. @@ -94,6 +94,37 @@ public function show_woocommerce_payments_payment_method_name( $payment_method_t return $payment_method_title; } + /** + * Returns the HTML to add the card brand logo and the last 4 digits of the card used to the + * payment method name on the order received page. + * + * @param WC_Order $order the order being shown. + * @param WCPay\Payment_Methods\UPE_Payment_Method $payment_method the payment method being shown. + * + * @return string + */ + public function show_card_payment_method_name( $order, $payment_method ) { + $card_brand = $order->get_meta( '_card_brand' ); + + if ( ! $card_brand ) { + return $payment_method->get_title(); + } + + ob_start(); + ?> + + '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; } /** @@ -531,78 +353,6 @@ public function get_product_data() { return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); } - /** - * Displays the necessary HTML for the Pay for Order page. - * - * @param WC_Order $order The order that needs payment. - */ - public function display_pay_for_order_page_html( $order ) { - $currency = get_woocommerce_currency(); - - $data = []; - $items = []; - - foreach ( $order->get_items() as $item ) { - if ( method_exists( $item, 'get_total' ) ) { - $items[] = [ - 'label' => $item->get_name(), - 'amount' => WC_Payments_Utils::prepare_amount( $item->get_total(), $currency ), - ]; - } - } - - if ( $order->get_total_tax() ) { - $items[] = [ - 'label' => __( 'Tax', 'woocommerce-payments' ), - 'amount' => WC_Payments_Utils::prepare_amount( $order->get_total_tax(), $currency ), - ]; - } - - if ( $order->get_shipping_total() ) { - $shipping_label = sprintf( - // Translators: %s is the name of the shipping method. - __( 'Shipping (%s)', 'woocommerce-payments' ), - $order->get_shipping_method() - ); - - $items[] = [ - 'label' => $shipping_label, - 'amount' => WC_Payments_Utils::prepare_amount( $order->get_shipping_total(), $currency ), - ]; - } - - foreach ( $order->get_fees() as $fee ) { - $items[] = [ - 'label' => $fee->get_name(), - 'amount' => WC_Payments_Utils::prepare_amount( $fee->get_amount(), $currency ), - ]; - } - - $data['order'] = $order->get_id(); - $data['displayItems'] = $items; - $data['needs_shipping'] = false; // This should be already entered/prepared. - $data['total'] = [ - 'label' => apply_filters( 'wcpay_payment_request_total_label', $this->express_checkout_helper->get_total_label() ), - 'amount' => WC_Payments_Utils::prepare_amount( $order->get_total(), $currency ), - 'pending' => true, - ]; - - wp_localize_script( 'WCPAY_PAYMENT_REQUEST', 'wcpayPaymentRequestPayForOrderParams', $data ); - } - - /** - * Get cart data. - * - * @return mixed Returns false if on a product page, the product information otherwise. - */ - public function get_cart_data() { - if ( $this->express_checkout_helper->is_product() ) { - return false; - } - - return $this->express_checkout_helper->build_display_items(); - } - /** * Filters the gateway title to reflect Payment Request type * @@ -900,7 +650,6 @@ public function scripts() { $payment_request_params = [ 'ajax_url' => admin_url( 'admin-ajax.php' ), - 'wc_ajax_url' => WC_AJAX::get_endpoint( '%%endpoint%%' ), 'stripe' => [ 'publishableKey' => $this->account->get_publishable_key( WC_Payments::mode()->is_test() ), 'accountId' => $this->account->get_stripe_account_id(), @@ -939,7 +688,7 @@ public function scripts() { 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), ]; - if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() && ( $this->express_checkout_helper->is_product() || $this->express_checkout_helper->is_pay_for_order_page() || $this->express_checkout_helper->is_cart() || $this->express_checkout_helper->is_checkout() ) ) { + if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { WC_Payments::register_script_with_dependencies( 'WCPAY_PAYMENT_REQUEST', 'dist/tokenized-payment-request', @@ -954,21 +703,6 @@ public function scripts() { [], WC_Payments::get_file_version( 'dist/tokenized-payment-request.css' ) ); - } else { - WC_Payments::register_script_with_dependencies( - 'WCPAY_PAYMENT_REQUEST', - 'dist/payment-request', - [ - 'jquery', - 'stripe', - ] - ); - WC_Payments_Utils::enqueue_style( - 'WCPAY_PAYMENT_REQUEST', - plugins_url( 'dist/payment-request.css', WCPAY_PLUGIN_FILE ), - [], - WC_Payments::get_file_version( 'dist/payment-request.css' ) - ); } wp_localize_script( 'WCPAY_PAYMENT_REQUEST', 'wcpayPaymentRequestParams', $payment_request_params ); @@ -1064,617 +798,6 @@ public function filter_cart_needs_shipping_address( $needs_shipping_address ) { return $needs_shipping_address; } - /** - * Get cart details. - */ - public function ajax_get_cart_details() { - check_ajax_referer( 'wcpay-get-cart-details', 'security' ); - - if ( ! defined( 'WOOCOMMERCE_CART' ) ) { - define( 'WOOCOMMERCE_CART', true ); - } - - if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { - define( 'WOOCOMMERCE_CHECKOUT', true ); - } - - WC()->cart->calculate_totals(); - - wp_send_json( array_merge( $this->express_checkout_helper->build_display_items(), [ 'needs_shipping' => WC()->cart->needs_shipping() ] ) ); - } - - /** - * Get shipping options. - * - * @see WC_Cart::get_shipping_packages(). - * @see WC_Shipping::calculate_shipping(). - * @see WC_Shipping::get_packages(). - */ - public function ajax_get_shipping_options() { - check_ajax_referer( 'wcpay-payment-request-shipping', 'security' ); - - $shipping_address = filter_input_array( - INPUT_POST, - [ - 'country' => FILTER_SANITIZE_SPECIAL_CHARS, - 'state' => FILTER_SANITIZE_SPECIAL_CHARS, - 'postcode' => FILTER_SANITIZE_SPECIAL_CHARS, - 'city' => FILTER_SANITIZE_SPECIAL_CHARS, - 'address_1' => FILTER_SANITIZE_SPECIAL_CHARS, - 'address_2' => FILTER_SANITIZE_SPECIAL_CHARS, - ] - ); - $product_view_options = filter_input_array( INPUT_POST, [ 'is_product_page' => FILTER_SANITIZE_SPECIAL_CHARS ] ); - $should_show_itemized_view = ! isset( $product_view_options['is_product_page'] ) ? true : filter_var( $product_view_options['is_product_page'], FILTER_VALIDATE_BOOLEAN ); - - $data = $this->get_shipping_options( $shipping_address, $should_show_itemized_view ); - wp_send_json( $data ); - } - - /** - * Gets shipping options available for specified shipping address - * - * @param array $shipping_address Shipping address. - * @param boolean $itemized_display_items Indicates whether to show subtotals or itemized views. - * - * @return array Shipping options data. - * - * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag - */ - public function get_shipping_options( $shipping_address, $itemized_display_items = false ) { - try { - // Set the shipping options. - $data = []; - - // Remember current shipping method before resetting. - $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); - $this->calculate_shipping( apply_filters( 'wcpay_payment_request_shipping_posted_values', $shipping_address ) ); - - $packages = WC()->shipping->get_packages(); - - if ( ! empty( $packages ) && WC()->customer->has_calculated_shipping() ) { - foreach ( $packages as $package ) { - if ( empty( $package['rates'] ) ) { - throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-payments' ) ); - } - - foreach ( $package['rates'] as $rate ) { - $data['shipping_options'][] = [ - 'id' => $rate->id, - 'label' => $rate->label, - 'detail' => '', - 'amount' => WC_Payments_Utils::prepare_amount( $rate->cost, get_woocommerce_currency() ), - ]; - } - } - } else { - throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-payments' ) ); - } - - // The first shipping option is automatically applied on the client. - // Keep chosen shipping method by sorting shipping options if the method still available for new address. - // Fallback to the first available shipping method. - if ( isset( $data['shipping_options'][0] ) ) { - if ( isset( $chosen_shipping_methods[0] ) ) { - $chosen_method_id = $chosen_shipping_methods[0]; - $compare_shipping_options = function ( $a, $b ) use ( $chosen_method_id ) { - if ( $a['id'] === $chosen_method_id ) { - return - 1; - } - - if ( $b['id'] === $chosen_method_id ) { - return 1; - } - - return 0; - }; - usort( $data['shipping_options'], $compare_shipping_options ); - } - - $first_shipping_method_id = $data['shipping_options'][0]['id']; - $this->update_shipping_method( [ $first_shipping_method_id ] ); - } - - WC()->cart->calculate_totals(); - - $this->maybe_restore_recurring_chosen_shipping_methods( $chosen_shipping_methods ); - - $data += $this->express_checkout_helper->build_display_items( $itemized_display_items ); - $data['result'] = 'success'; - } catch ( Exception $e ) { - $data += $this->express_checkout_helper->build_display_items( $itemized_display_items ); - $data['result'] = 'invalid_shipping_address'; - } - - return $data; - } - - /** - * Update shipping method. - */ - public function ajax_update_shipping_method() { - check_ajax_referer( 'wcpay-update-shipping-method', 'security' ); - - if ( ! defined( 'WOOCOMMERCE_CART' ) ) { - define( 'WOOCOMMERCE_CART', true ); - } - - $shipping_methods = filter_input( INPUT_POST, 'shipping_method', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY ); - $this->update_shipping_method( $shipping_methods ); - - WC()->cart->calculate_totals(); - - $product_view_options = filter_input_array( INPUT_POST, [ 'is_product_page' => FILTER_SANITIZE_SPECIAL_CHARS ] ); - $should_show_itemized_view = ! isset( $product_view_options['is_product_page'] ) ? true : filter_var( $product_view_options['is_product_page'], FILTER_VALIDATE_BOOLEAN ); - - $data = []; - $data += $this->express_checkout_helper->build_display_items( $should_show_itemized_view ); - $data['result'] = 'success'; - - wp_send_json( $data ); - } - - /** - * Updates shipping method in WC session - * - * @param array $shipping_methods Array of selected shipping methods ids. - */ - public function update_shipping_method( $shipping_methods ) { - $chosen_shipping_methods = (array) WC()->session->get( 'chosen_shipping_methods' ); - - if ( is_array( $shipping_methods ) ) { - foreach ( $shipping_methods as $i => $value ) { - $chosen_shipping_methods[ $i ] = wc_clean( $value ); - } - } - - WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); - } - - /** - * Gets the selected product data. - * - * @throws Exception If product or stock is unavailable - caught inside function. - */ - public function ajax_get_selected_product_data() { - check_ajax_referer( 'wcpay-get-selected-product-data', 'security' ); - - try { - $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; - $qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id ); - $addon_value = isset( $_POST['addon_value'] ) ? max( (float) $_POST['addon_value'], 0 ) : 0; - $product = wc_get_product( $product_id ); - $variation_id = null; - $currency = get_woocommerce_currency(); - $is_deposit = isset( $_POST['wc_deposit_option'] ) ? 'yes' === sanitize_text_field( wp_unslash( $_POST['wc_deposit_option'] ) ) : null; - $deposit_plan_id = isset( $_POST['wc_deposit_payment_plan'] ) ? absint( $_POST['wc_deposit_payment_plan'] ) : 0; - - if ( ! is_a( $product, 'WC_Product' ) ) { - /* translators: product ID */ - throw new Exception( sprintf( __( 'Product with the ID (%d) cannot be found.', 'woocommerce-payments' ), $product_id ) ); - } - - if ( ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) && isset( $_POST['attributes'] ) ) { - $attributes = wc_clean( wp_unslash( $_POST['attributes'] ) ); - - $data_store = WC_Data_Store::load( 'product' ); - $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); - - if ( ! empty( $variation_id ) ) { - $product = wc_get_product( $variation_id ); - } - } - - // Force quantity to 1 if sold individually and check for existing item in cart. - if ( $product->is_sold_individually() ) { - $qty = apply_filters( 'wcpay_payment_request_add_to_cart_sold_individually_quantity', 1, $qty, $product_id, $variation_id ); - } - - if ( ! $product->has_enough_stock( $qty ) ) { - /* translators: 1: product name 2: quantity in stock */ - throw new Exception( sprintf( __( 'You cannot add that amount of "%1$s"; to the cart because there is not enough stock (%2$s remaining).', 'woocommerce-payments' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product ) ) ); - } - - $price = $this->get_product_price( $product, $is_deposit, $deposit_plan_id ); - $total = $qty * $price + $addon_value; - - $quantity_label = 1 < $qty ? ' (x' . $qty . ')' : ''; - - $data = []; - $items = []; - - $items[] = [ - 'label' => $product->get_name() . $quantity_label, - 'amount' => WC_Payments_Utils::prepare_amount( $total, $currency ), - ]; - - $total_tax = 0; - foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { - $total_tax += $tax; - - $items[] = [ - 'label' => __( 'Tax', 'woocommerce-payments' ), - 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), - 'pending' => 0 === $tax, - ]; - } - - if ( wc_shipping_enabled() && $product->needs_shipping() ) { - $items[] = [ - 'label' => __( 'Shipping', 'woocommerce-payments' ), - 'amount' => 0, - 'pending' => true, - ]; - - $data['shippingOptions'] = [ - 'id' => 'pending', - 'label' => __( 'Pending', 'woocommerce-payments' ), - 'detail' => '', - 'amount' => 0, - ]; - } - - $data['displayItems'] = $items; - $data['total'] = [ - 'label' => $this->express_checkout_helper->get_total_label(), - 'amount' => WC_Payments_Utils::prepare_amount( $total + $total_tax, $currency ), - 'pending' => true, - ]; - - $data['needs_shipping'] = ( wc_shipping_enabled() && $product->needs_shipping() ); - $data['currency'] = strtolower( get_woocommerce_currency() ); - $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); - - wp_send_json( $data ); - } catch ( Exception $e ) { - if ( is_a( $e, Invalid_Price_Exception::class ) ) { - Logger::log( $e->getMessage() ); - } - wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ], 500 ); - } - } - - /** - * Handles payment requests on the Pay for Order page. - * - * @throws Exception All exceptions are handled within the method. - */ - public function ajax_pay_for_order() { - check_ajax_referer( 'pay_for_order' ); - - if ( - ! isset( $_POST['payment_method'] ) || 'woocommerce_payments' !== $_POST['payment_method'] - || ! isset( $_POST['order'] ) || ! intval( $_POST['order'] ) - || ! isset( $_POST['wcpay-payment-method'] ) || empty( $_POST['wcpay-payment-method'] ) - ) { - // Incomplete request. - $response = [ - 'result' => 'error', - 'messages' => __( 'Invalid request', 'woocommerce-payments' ), - ]; - wp_send_json( $response, 400 ); - - return; - } - - try { - // Set up an environment, similar to core checkout. - wc_maybe_define_constant( 'WOOCOMMERCE_CHECKOUT', true ); - wc_set_time_limit( 0 ); - - // Load the order. - $order_id = intval( $_POST['order'] ); - $order = wc_get_order( $order_id ); - - if ( ! is_a( $order, WC_Order::class ) ) { - throw new Exception( __( 'Invalid order!', 'woocommerce-payments' ) ); - } - - if ( ! $order->needs_payment() ) { - throw new Exception( __( 'This order does not require payment!', 'woocommerce-payments' ) ); - } - - $this->add_order_meta( $order_id ); - - // Load the gateway. - $all_gateways = WC()->payment_gateways->get_available_payment_gateways(); - $gateway = $all_gateways['woocommerce_payments']; - $result = $gateway->process_payment( $order_id ); - - // process_payment() should only return `success` or throw an exception. - if ( ! is_array( $result ) || ! isset( $result['result'] ) || 'success' !== $result['result'] || ! isset( $result['redirect'] ) ) { - throw new Exception( __( 'Unable to determine payment success.', 'woocommerce-payments' ) ); - } - - // Include the order ID in the result. - $result['order_id'] = $order_id; - - $result = apply_filters( 'woocommerce_payment_successful_result', $result, $order_id ); - } catch ( Exception $e ) { - $result = [ - 'result' => 'error', - 'messages' => $e->getMessage(), - ]; - } - - wp_send_json( $result ); - } - - /** - * Normalizes billing and shipping state fields. - */ - public function normalize_state() { - check_ajax_referer( 'woocommerce-process_checkout', '_wpnonce' ); - - $billing_country = ! empty( $_POST['billing_country'] ) ? wc_clean( wp_unslash( $_POST['billing_country'] ) ) : ''; - $shipping_country = ! empty( $_POST['shipping_country'] ) ? wc_clean( wp_unslash( $_POST['shipping_country'] ) ) : ''; - $billing_state = ! empty( $_POST['billing_state'] ) ? wc_clean( wp_unslash( $_POST['billing_state'] ) ) : ''; - $shipping_state = ! empty( $_POST['shipping_state'] ) ? wc_clean( wp_unslash( $_POST['shipping_state'] ) ) : ''; - - if ( $billing_state && $billing_country ) { - $_POST['billing_state'] = $this->get_normalized_state( $billing_state, $billing_country ); - } - - if ( $shipping_state && $shipping_country ) { - $_POST['shipping_state'] = $this->get_normalized_state( $shipping_state, $shipping_country ); - } - } - - /** - * 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 ); - } - - /** - * The Payment Request API provides its own validation for the address form. - * For some countries, it might not provide a state field, so we need to return a more descriptive - * error message, indicating that the Payment Request button is not supported for that country. - */ - public function validate_state() { - $wc_checkout = WC_Checkout::instance(); - $posted_data = $wc_checkout->get_posted_data(); - $checkout_fields = $wc_checkout->get_checkout_fields(); - $countries = WC()->countries->get_countries(); - - $is_supported = true; - // Checks if billing state is missing and is required. - if ( ! empty( $checkout_fields['billing']['billing_state']['required'] ) && '' === $posted_data['billing_state'] ) { - $is_supported = false; - } - - // Checks if shipping state is missing and is required. - if ( WC()->cart->needs_shipping_address() && ! empty( $checkout_fields['shipping']['shipping_state']['required'] ) && '' === $posted_data['shipping_state'] ) { - $is_supported = false; - } - - if ( ! $is_supported ) { - wc_add_notice( - sprintf( - /* translators: %s: country. */ - __( 'The payment request button is not supported in %s because some required fields couldn\'t be verified. Please proceed to the checkout page and try again.', 'woocommerce-payments' ), - $countries[ $posted_data['billing_country'] ] ?? $posted_data['billing_country'] - ), - 'error' - ); - } - } - - /** - * Create order. Security is handled by WC. - */ - public function ajax_create_order() { - if ( WC()->cart->is_empty() ) { - wp_send_json_error( __( 'Empty cart', 'woocommerce-payments' ), 400 ); - } - - if ( ! defined( 'WOOCOMMERCE_CHECKOUT' ) ) { - define( 'WOOCOMMERCE_CHECKOUT', true ); - } - - if ( ! defined( 'WCPAY_PAYMENT_REQUEST_CHECKOUT' ) ) { - define( 'WCPAY_PAYMENT_REQUEST_CHECKOUT', true ); - } - - // In case the state is required, but is missing, add a more descriptive error notice. - $this->validate_state(); - - $this->normalize_state(); - - WC()->checkout()->process_checkout(); - - die( 0 ); - } - - /** - * Calculate and set shipping method. - * - * @param array $address Shipping address. - */ - protected function calculate_shipping( $address = [] ) { - $country = $address['country']; - $state = $address['state']; - $postcode = $address['postcode']; - $city = $address['city']; - $address_1 = $address['address_1']; - $address_2 = $address['address_2']; - - // Normalizes state to calculate shipping zones. - $state = $this->get_normalized_state( $state, $country ); - - // Normalizes postal code in case of redacted data from Apple Pay. - $postcode = $this->get_normalized_postal_code( $postcode, $country ); - - WC()->shipping->reset_shipping(); - - if ( $postcode && WC_Validation::is_postcode( $postcode, $country ) ) { - $postcode = wc_format_postcode( $postcode, $country ); - } - - if ( $country ) { - WC()->customer->set_location( $country, $state, $postcode, $city ); - WC()->customer->set_shipping_location( $country, $state, $postcode, $city ); - } else { - WC()->customer->set_billing_address_to_base(); - WC()->customer->set_shipping_address_to_base(); - } - - WC()->customer->set_calculated_shipping( true ); - WC()->customer->save(); - - $packages = []; - - $packages[0]['contents'] = WC()->cart->get_cart(); - $packages[0]['contents_cost'] = 0; - $packages[0]['applied_coupons'] = WC()->cart->applied_coupons; - $packages[0]['user']['ID'] = get_current_user_id(); - $packages[0]['destination']['country'] = $country; - $packages[0]['destination']['state'] = $state; - $packages[0]['destination']['postcode'] = $postcode; - $packages[0]['destination']['city'] = $city; - $packages[0]['destination']['address'] = $address_1; - $packages[0]['destination']['address_2'] = $address_2; - - foreach ( WC()->cart->get_cart() as $item ) { - if ( $item['data']->needs_shipping() ) { - if ( isset( $item['line_total'] ) ) { - $packages[0]['contents_cost'] += $item['line_total']; - } - } - } - - $packages = apply_filters( 'woocommerce_cart_shipping_packages', $packages ); - - WC()->shipping->calculate_shipping( $packages ); - } - - /** - * Builds the shipping methods to pass to Payment Request - * - * @param array $shipping_methods Shipping methods. - */ - protected function build_shipping_methods( $shipping_methods ) { - if ( empty( $shipping_methods ) ) { - return []; - } - - $shipping = []; - - foreach ( $shipping_methods as $method ) { - $shipping[] = [ - 'id' => $method['id'], - 'label' => $method['label'], - 'detail' => '', - 'amount' => WC_Payments_Utils::prepare_amount( $method['amount']['value'], get_woocommerce_currency() ), - ]; - } - - return $shipping; - } - /** * Calculates whether Apple Pay is enabled for this store. * The option value is not stored in the database, and is calculated @@ -1747,40 +870,4 @@ private function get_taxes_like_cart( $product, $price ) { // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. return WC_Tax::calc_tax( $price, $rates, false ); } - - /** - * Restores the shipping methods previously chosen for each recurring cart after shipping was reset and recalculated - * during the Payment Request get_shipping_options flow. - * - * When the cart contains multiple subscriptions with different billing periods, customers are able to select different shipping - * methods for each subscription, however, this is not supported when purchasing with Apple Pay and Google Pay as it's - * only concerned about handling the initial purchase. - * - * In order to avoid Woo Subscriptions's `WC_Subscriptions_Cart::validate_recurring_shipping_methods` throwing an error, we need to restore - * the previously chosen shipping methods for each recurring cart. - * - * This function needs to be called after `WC()->cart->calculate_totals()` is run, otherwise `WC()->cart->recurring_carts` won't exist yet. - * - * @param array $previous_chosen_methods The previously chosen shipping methods. - */ - private function maybe_restore_recurring_chosen_shipping_methods( $previous_chosen_methods = [] ) { - if ( empty( WC()->cart->recurring_carts ) || ! method_exists( 'WC_Subscriptions_Cart', 'get_recurring_shipping_package_key' ) ) { - return; - } - - $chosen_shipping_methods = WC()->session->get( 'chosen_shipping_methods', [] ); - - foreach ( WC()->cart->recurring_carts as $recurring_cart_key => $recurring_cart ) { - foreach ( $recurring_cart->get_shipping_packages() as $recurring_cart_package_index => $recurring_cart_package ) { - $package_key = WC_Subscriptions_Cart::get_recurring_shipping_package_key( $recurring_cart_key, $recurring_cart_package_index ); - - // If the recurring cart package key is found in the previous chosen methods, but not in the current chosen methods, restore it. - if ( isset( $previous_chosen_methods[ $package_key ] ) && ! isset( $chosen_shipping_methods[ $package_key ] ) ) { - $chosen_shipping_methods[ $package_key ] = $previous_chosen_methods[ $package_key ]; - } - } - } - - WC()->session->set( 'chosen_shipping_methods', $chosen_shipping_methods ); - } } 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-status.php b/includes/class-wc-payments-status.php index f0e51a2a4f6..8ed632666fd 100644 --- a/includes/class-wc-payments-status.php +++ b/includes/class-wc-payments-status.php @@ -243,7 +243,12 @@ function ( $rule ) { : - + + gateway->get_option( 'manual_capture' ); + echo $manual_capture_enabled ? esc_html_e( 'Enabled', 'woocommerce-payments' ) : esc_html_e( 'Disabled', 'woocommerce-payments' ); + ?> + : 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 eceb9617a75..0cad2ffe950 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -414,6 +414,7 @@ private function process_webhook_payment_intent_failed( $event_body ) { $actionable_methods = [ Payment_Method::CARD, + Payment_Method::CARD_PRESENT, Payment_Method::US_BANK_ACCOUNT, Payment_Method::BECS, ]; @@ -423,12 +424,14 @@ private function process_webhook_payment_intent_failed( $event_body ) { } // Get the order and make sure it is an order and the payment methods match. - $order = $this->get_order_from_event_body_intent_id( $event_body ); + $order = $this->get_order_from_event_body( $event_body ); $payment_method_id = $payment_method['id'] ?? null; - if ( ! $order - || empty( $payment_method_id ) - || $payment_method_id !== $order->get_meta( '_payment_method_id' ) ) { + if ( ! $order || empty( $payment_method_id ) ) { + return; + } + + if ( Payment_Method::CARD_PRESENT !== $payment_method_type && $payment_method_id !== $order->get_meta( '_payment_method_id' ) ) { return; } @@ -437,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. @@ -452,7 +456,7 @@ private function process_webhook_payment_intent_succeeded( $event_body ) { $event_object = $this->read_webhook_property( $event_data, 'object' ); $intent_id = $this->read_webhook_property( $event_object, 'id' ); $currency = $this->read_webhook_property( $event_object, 'currency' ); - $order = $this->get_order_from_event_body_intent_id( $event_body ); + $order = $this->get_order_from_event_body( $event_body ); $intent_status = $this->read_webhook_property( $event_object, 'status' ); $event_charges = $this->read_webhook_property( $event_object, 'charges' ); $charges_data = $this->read_webhook_property( $event_charges, 'data' ); @@ -704,16 +708,16 @@ private function has_webhook_property( $array, $key ) { } /** - * Gets the order related to the event intent id. + * Gets the order related to the event. * * @param array $event_body The event that triggered the webhook. * * @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_intent_id( $event_body ) { + private function get_order_from_event_body( $event_body ) { $event_data = $this->read_webhook_property( $event_body, 'data' ); $event_object = $this->read_webhook_property( $event_data, 'object' ); $intent_id = $this->read_webhook_property( $event_object, 'id' ); @@ -721,21 +725,27 @@ private function get_order_from_event_body_intent_id( $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' ); + $order_id = $metadata['order_id'] ?? null; + // If metadata order id is null, try to read from the charges metadata. + if ( null === $order_id ) { + $charges = $this->read_webhook_property( $event_object, 'charges' ); + $charge = $charges[0] ?? []; + $order_id = $charge['metadata']['order_id'] ?? null; + } - if ( isset( $metadata['order_id'] ) ) { - $order_id = $metadata['order_id']; - $order = $this->wcpay_db->order_from_order_id( $order_id ); + if ( $order_id ) { + $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-woopay-button-handler.php b/includes/class-wc-payments-woopay-button-handler.php index a5cdac1d16b..a42fe8046a4 100644 --- a/includes/class-wc-payments-woopay-button-handler.php +++ b/includes/class-wc-payments-woopay-button-handler.php @@ -331,7 +331,6 @@ public function display_woopay_button_html() { } $settings = $this->get_button_settings(); - $radius = WC_Payments_Features::is_stripe_ece_enabled() ? $settings['radius'] : WC_Payments_Express_Checkout_Button_Handler::DEFAULT_BORDER_RADIUS_IN_PX; ?>
express_checkout_helper->is_product() ); ?>> @@ -342,7 +341,7 @@ class="woopay-express-button" data-type="" data-theme="" data-size="" - style="height: px; border-radius: px" + style="height: px; border-radius: px" disabled >
diff --git a/includes/class-wc-payments-woopay-direct-checkout.php b/includes/class-wc-payments-woopay-direct-checkout.php index 6c2d170f4fe..6705bdf2332 100644 --- a/includes/class-wc-payments-woopay-direct-checkout.php +++ b/includes/class-wc-payments-woopay-direct-checkout.php @@ -1,14 +1,11 @@ init(); } } @@ -1882,13 +1880,14 @@ 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' ); + 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 @@ cart->empty_cart(); + if ( empty( $payment_data['payment_request_type'] ) ) { + return; + } + + $payment_request_type = wc_clean( wp_unslash( $payment_data['payment_request_type'] ) ); + + $payment_method_titles = [ + 'apple_pay' => 'Apple Pay', + 'google_pay' => 'Google Pay', + ]; + + $suffix = apply_filters( 'wcpay_payment_request_payment_method_title_suffix', 'WooPayments' ); + if ( ! empty( $suffix ) ) { + $suffix = " ($suffix)"; + } + + $payment_method_title = isset( $payment_method_titles[ $payment_request_type ] ) ? $payment_method_titles[ $payment_request_type ] : 'Payment Request'; + $order->set_payment_method_title( $payment_method_title . $suffix ); + } + + /** + * Google Pay/Apple Pay parameters for address data might need some massaging for some of the countries. + * Ensuring that the Store API doesn't throw a `rest_invalid_param` error message for some of those scenarios. + * + * @param mixed $response Response to replace the requested version with. + * @param \WP_REST_Server $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed + */ + public function tokenized_cart_store_api_address_normalization( $response, $server, $request ) { + if ( 'true' !== $request->get_header( 'X-WooPayments-Tokenized-Cart' ) ) { + return $response; + } + + // header added as additional layer of security. + $nonce = $request->get_header( 'X-WooPayments-Tokenized-Cart-Nonce' ); + if ( ! wp_verify_nonce( $nonce, 'woopayments_tokenized_cart_nonce' ) ) { + return $response; + } + + // This route is used to get shipping rates. + // GooglePay/ApplePay might provide us with "trimmed" zip codes. + // If that's the case, let's temporarily allow to skip the zip code validation, in order to get some shipping rates. + $is_update_customer_route = $request->get_route() === '/wc/store/v1/cart/update-customer'; + if ( $is_update_customer_route ) { + add_filter( 'woocommerce_validate_postcode', [ $this, 'maybe_skip_postcode_validation' ], 10, 3 ); + } + + $request_data = $request->get_json_params(); + if ( isset( $request_data['shipping_address'] ) ) { + $request->set_param( 'shipping_address', $this->transform_ece_address_state_data( $request_data['shipping_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'shipping_address', $this->transform_ece_address_postcode_data( $request_data['shipping_address'] ) ); + } + } + if ( isset( $request_data['billing_address'] ) ) { + $request->set_param( 'billing_address', $this->transform_ece_address_state_data( $request_data['billing_address'] ) ); + // on the "update customer" route, GooglePay/Apple pay might provide redacted postcode data. + // we need to modify the zip code to ensure that shipping zone identification still works. + if ( $is_update_customer_route ) { + $request->set_param( 'billing_address', $this->transform_ece_address_postcode_data( $request_data['billing_address'] ) ); + } + } + + return $response; + } + + /** + * 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; + } - if ( $booking_id ) { - // When a bookable product is added to the cart, a 'booking' is create with status 'in-cart'. - // This status is used to prevent the booking from being booked by another customer - // and should be removed when the cart is emptied for PRB purposes. - do_action( 'wc-booking-remove-inactive-cart', $booking_id ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores + // 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 ); } - wp_send_json( [ 'result' => 'success' ] ); + 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 4a78920db88..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 @@ -21,13 +21,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { */ private $gateway; - /** - * Instance of WC_Payments_Payment_Request_Button_Handler, created in init function - * - * @var WC_Payments_Payment_Request_Button_Handler - */ - private $payment_request_button_handler; - /** * Instance of WC_Payments_WooPay_Button_Handler, created in init function * @@ -60,7 +53,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { * Initialize class actions. * * @param WC_Payment_Gateway_WCPay $gateway WCPay gateway. - * @param WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler Payment request button handler. * @param WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler Platform checkout button handler. * @param WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler Express Checkout Element button handler. * @param WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler Express checkout ajax handlers. @@ -68,14 +60,12 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { */ public function __construct( WC_Payment_Gateway_WCPay $gateway, - WC_Payments_Payment_Request_Button_Handler $payment_request_button_handler, WC_Payments_WooPay_Button_Handler $platform_checkout_button_handler, WC_Payments_Express_Checkout_Button_Handler $express_checkout_button_handler, WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler, WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper ) { $this->gateway = $gateway; - $this->payment_request_button_handler = $payment_request_button_handler; $this->platform_checkout_button_handler = $platform_checkout_button_handler; $this->express_checkout_button_handler = $express_checkout_button_handler; $this->express_checkout_ajax_handler = $express_checkout_ajax_handler; @@ -89,7 +79,6 @@ public function __construct( */ public function init() { $this->platform_checkout_button_handler->init(); - $this->payment_request_button_handler->init(); $this->express_checkout_button_handler->init(); $is_woopay_enabled = WC_Payments_Features::is_woopay_enabled(); @@ -97,7 +86,6 @@ public function init() { if ( $is_woopay_enabled || $is_payment_request_enabled ) { add_action( 'wc_ajax_wcpay_add_to_cart', [ $this->express_checkout_ajax_handler, 'ajax_add_to_cart' ] ); - add_action( 'wc_ajax_wcpay_empty_cart', [ $this->express_checkout_ajax_handler, 'ajax_empty_cart' ] ); add_action( 'woocommerce_after_add_to_cart_form', [ $this, 'display_express_checkout_buttons' ], 1 ); add_action( 'woocommerce_proceed_to_checkout', [ $this, 'display_express_checkout_buttons' ], 21 ); @@ -105,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 ); } @@ -117,10 +107,9 @@ public function init() { * @return void */ public function display_express_checkout_separator_if_necessary( $separator_starts_hidden = false ) { - $html_id = WC_Payments_Features::is_stripe_ece_enabled() ? 'wcpay-express-checkout-button-separator' : 'wcpay-payment-request-button-separator'; if ( $this->express_checkout_helper->is_checkout() ) { ?> -

+

platform_checkout_button_handler->should_show_woopay_button(); - $should_show_payment_request = $this->payment_request_button_handler->should_show_payment_request_button(); $should_show_express_checkout_button = $this->express_checkout_helper->should_show_express_checkout_button(); // When Payment Request button is enabled, we need the separator markup on the page, but hidden in case the browser doesn't have any payment request methods to display. // More details: https://github.com/Automattic/woocommerce-payments/pull/5399#discussion_r1073633776. $separator_starts_hidden = ! $should_show_woopay; - if ( $should_show_woopay || $should_show_payment_request || $should_show_express_checkout_button ) { + if ( $should_show_woopay || $should_show_express_checkout_button ) { ?> -
+
express_checkout_helper->is_pay_for_order_page() || $this->is_pay_for_order_flow_supported() ) { $this->platform_checkout_button_handler->display_woopay_button_html(); } - if ( WC_Payments_Features::is_stripe_ece_enabled() ) { - $this->express_checkout_button_handler->display_express_checkout_button_html(); - } else { - $this->payment_request_button_handler->display_payment_request_button_html(); - } + $this->express_checkout_button_handler->display_express_checkout_button_html(); if ( is_cart() ) { add_action( 'woocommerce_after_cart', [ $this, 'add_order_attribution_inputs' ], 1 ); @@ -236,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 9deeab01ca3..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 @@ -76,10 +76,6 @@ public function init() { return; } - if ( ! WC_Payments_Features::is_stripe_ece_enabled() ) { - return; - } - // Checks if Payment Request is enabled. if ( 'yes' !== $this->gateway->get_option( 'payment_request' ) ) { return; @@ -113,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 ); } /** @@ -226,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' => [ @@ -235,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. @@ -255,25 +256,47 @@ public function scripts() { ], 'button' => $this->get_button_settings(), 'login_confirmation' => $this->get_login_confirmation_settings(), - 'is_product_page' => $this->express_checkout_helper->is_product(), 'button_context' => $this->express_checkout_helper->get_button_context(), - 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), 'product' => $this->express_checkout_helper->get_product_data(), 'total_label' => $this->express_checkout_helper->get_total_label(), - 'is_checkout_page' => $this->express_checkout_helper->is_checkout(), ]; - WC_Payments::register_script_with_dependencies( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'dist/express-checkout', [ 'jquery', 'stripe' ] ); + if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { + WC_Payments::register_script_with_dependencies( + 'WCPAY_EXPRESS_CHECKOUT_ECE', + 'dist/tokenized-express-checkout', + [ + 'jquery', + 'stripe', + ] + ); - WC_Payments_Utils::enqueue_style( - 'WCPAY_EXPRESS_CHECKOUT_ECE', - plugins_url( 'dist/payment-request.css', WCPAY_PLUGIN_FILE ), - [], - WC_Payments::get_file_version( 'dist/payment-request.css' ) - ); + WC_Payments_Utils::enqueue_style( + 'WCPAY_EXPRESS_CHECKOUT_ECE', + plugins_url( 'dist/tokenized-express-checkout.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/tokenized-express-checkout.css' ) + ); + } else { + WC_Payments::register_script_with_dependencies( + 'WCPAY_EXPRESS_CHECKOUT_ECE', + 'dist/express-checkout', + [ + 'jquery', + 'stripe', + ] + ); - wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $payment_request_params ); + WC_Payments_Utils::enqueue_style( + 'WCPAY_EXPRESS_CHECKOUT_ECE', + plugins_url( 'dist/express-checkout.css', WCPAY_PLUGIN_FILE ), + [], + WC_Payments::get_file_version( 'dist/express-checkout.css' ) + ); + } + + wp_localize_script( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'wcpayExpressCheckoutParams', $express_checkout_params ); wp_set_script_translations( 'WCPAY_EXPRESS_CHECKOUT_ECE', 'woocommerce-payments' ); @@ -466,10 +489,11 @@ private function register_ece_data_for_block_editor() { return; } - $ece_data = [ - 'button' => $this->get_button_settings(), - ]; - - $data_registry->add( 'ece_data', $ece_data ); + $data_registry->add( + 'ece_data', + [ + 'button' => $this->get_button_settings(), + ] + ); } } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php index 1a189749dff..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 @@ -251,17 +251,13 @@ public function is_available_at( $location, $option_name ) { */ public function get_common_button_settings() { $button_type = $this->gateway->get_option( 'payment_request_button_type' ); - $settings = [ + + return [ 'type' => $button_type, 'theme' => $this->gateway->get_option( 'payment_request_button_theme' ), 'height' => $this->get_button_height(), + 'radius' => $this->gateway->get_option( 'payment_request_button_border_radius' ), ]; - - if ( WC_Payments_Features::is_stripe_ece_enabled() ) { - $settings['radius'] = $this->gateway->get_option( 'payment_request_button_border_radius' ); - } - - return $settings; } /** @@ -431,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; } @@ -893,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 ) ) { @@ -980,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/payment-methods/class-affirm-payment-method.php b/includes/payment-methods/class-affirm-payment-method.php index 24dba99b021..47b89f49951 100644 --- a/includes/payment-methods/class-affirm-payment-method.php +++ b/includes/payment-methods/class-affirm-payment-method.php @@ -20,7 +20,7 @@ class Affirm_Payment_Method extends UPE_Payment_Method { const PAYMENT_METHOD_STRIPE_ID = 'affirm'; /** - * Constructor for link payment method + * Constructor for Affirm payment method * * @param WC_Payments_Token_Service $token_service Token class instance. */ diff --git a/includes/payment-methods/class-afterpay-payment-method.php b/includes/payment-methods/class-afterpay-payment-method.php index a0a4acbbf30..4cc9b027e8c 100644 --- a/includes/payment-methods/class-afterpay-payment-method.php +++ b/includes/payment-methods/class-afterpay-payment-method.php @@ -20,7 +20,7 @@ class Afterpay_Payment_Method extends UPE_Payment_Method { const PAYMENT_METHOD_STRIPE_ID = 'afterpay_clearpay'; /** - * Constructor for link payment method + * Constructor for Afterpay payment method * * @param WC_Payments_Token_Service $token_service Token class instance. */ diff --git a/includes/payment-methods/class-klarna-payment-method.php b/includes/payment-methods/class-klarna-payment-method.php index aaea0697423..31c71cb813a 100644 --- a/includes/payment-methods/class-klarna-payment-method.php +++ b/includes/payment-methods/class-klarna-payment-method.php @@ -20,7 +20,7 @@ class Klarna_Payment_Method extends UPE_Payment_Method { const PAYMENT_METHOD_STRIPE_ID = 'klarna'; /** - * Constructor for link payment method + * Constructor for Klarna payment method * * @param WC_Payments_Token_Service $token_service Token class instance. */ 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 83cd7a25f41..4d8d73b8e87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "woocommerce-payments", - "version": "8.5.1", + "version": "8.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "woocommerce-payments", - "version": "8.5.1", + "version": "8.6.0", "hasInstallScript": true, "license": "GPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index c62ba8fd0ec..4fa803a245c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "woocommerce-payments", - "version": "8.5.1", + "version": "8.6.0", "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 59e8fefc1cd..d67f01c3951 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.1 +Stable tag: 8.6.0 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -89,11 +89,44 @@ Please note that our support for the checkout block is still experimental and th 1. View Transactions 2. View Transaction Details -3. Track Deposits +3. Track Payouts 4. Manage Disputes == Changelog == += 8.6.0 - 2024-12-04 = +* Add - Add Bank reference key column in Payout reports. This will help reconcile WooPayments Payouts with bank statements. +* Add - Display credit card brand icons on order received page. +* Fix - Add support to load stripe js asynchronously when it is not immediately available in the global scope. +* Fix - Add the missing "Download" column heading label and toggle menu option to the Payments → Documents list view table. +* Fix - Ensure ECE button load events are triggered for multiple buttons on the same page. +* Fix - Ensure ECE is displayed correctly taking into account the tax settings. +* Fix - Evidence submission is no longer available for Klarna inquiries as this is not supported by Stripe / Klarna. +* Fix - fix: express checkout to use its own css files. +* Fix - fix: missing ece is_product_page checks +* Fix - Fix ECE Tracks events not triggering when WooPay is disabled. +* Fix - Fix WooPay component spacing. +* Fix - Fix WooPay trial subscriptions purchases. +* Fix - Make sure the status of manual capture enablement is fetched from the right place. +* Fix - Prevent express checkout from being used if cart total becomes zero after coupon usage. +* Fix - Resolved issue with terminal payments in the payment intent failed webhook processing. +* Fix - Set the support phone field as mandatory in the settings page. +* Fix - Update Link logo alignment issue when WooPay is enabled and a specific version of Gutenberg is enabled. +* Fix - Use paragraph selector instead of label for pmme appearance +* Fix - Validate required billing fields using data from objects instead of checking the labels. +* Update - Avoid getting the appearance for pay for order page with the wrong appearance key. +* Update - chore: rename wrapper from payment-request to express-checkout +* Update - feat: add `wcpay_checkout_use_plain_method_label` filter to allow themes or merchants to force the "plain" WooPayments label on shortcode checkout. +* Update - refactor: express checkout initialization page location checks +* Update - refactor: express checkout utility for button UI interactions +* Dev - Allow redirect to the settings page from WCPay connect +* Dev - chore: removed old PRB implementation for ApplePay/GooglePay in favor of the ECE implementation; cleaned up ECE feature flag; +* Dev - Disable visual regression testing from Playwright until a more reliable approach is defined. +* Dev - Ensure proper return types in the webhook processing service. +* Dev - fix: E_DEPRECATED on BNPL empty PMME +* Dev - Fix return types +* Dev - Update snapshots for E2E Playwright screenshots + = 8.5.1 - 2024-11-25 = * Fix - fix: remove "test mode" badge from shortcode checkout. diff --git a/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency-on-boarding.spec.ts/Multi-currency-on-boarding-Geolocation-feature-83665-tch-by-geolocation-correctly-with-USD-and-GBP-1.png b/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency-on-boarding.spec.ts/Multi-currency-on-boarding-Geolocation-feature-83665-tch-by-geolocation-correctly-with-USD-and-GBP-1.png index 302a722e0a6..7cfb7e2649d 100644 Binary files a/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency-on-boarding.spec.ts/Multi-currency-on-boarding-Geolocation-feature-83665-tch-by-geolocation-correctly-with-USD-and-GBP-1.png and b/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency-on-boarding.spec.ts/Multi-currency-on-boarding-Geolocation-feature-83665-tch-by-geolocation-correctly-with-USD-and-GBP-1.png differ diff --git a/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency.spec.ts/Multi-currency-page-load-without-any-errors-1.png b/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency.spec.ts/Multi-currency-page-load-without-any-errors-1.png index 17d9ff94c0e..fdfd9a99e2d 100644 Binary files a/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency.spec.ts/Multi-currency-page-load-without-any-errors-1.png and b/tests/e2e-pw/specs/merchant/__snapshots__/multi-currency.spec.ts/Multi-currency-page-load-without-any-errors-1.png differ diff --git a/tests/e2e-pw/specs/merchant/merchant-admin-deposits.spec.ts b/tests/e2e-pw/specs/merchant/merchant-admin-deposits.spec.ts index 6c2e6b41e72..96008887630 100644 --- a/tests/e2e-pw/specs/merchant/merchant-admin-deposits.spec.ts +++ b/tests/e2e-pw/specs/merchant/merchant-admin-deposits.spec.ts @@ -54,8 +54,9 @@ test.describe( 'Merchant deposits', () => { await page.evaluate( () => { window.scrollTo( 0, 0 ); } ); - await expect( - page.locator( '.woocommerce-filters' ).last() - ).toHaveScreenshot(); + // TODO: This visual regression test is not flaky, but we should revisit the approach. + // await expect( + // page.locator( '.woocommerce-filters' ).last() + // ).toHaveScreenshot(); } ); } ); diff --git a/tests/e2e-pw/specs/merchant/merchant-disputes-view-details-via-order-notice.spec.ts b/tests/e2e-pw/specs/merchant/merchant-disputes-view-details-via-order-notice.spec.ts index 1e55cafba1e..b7da795ce06 100644 --- a/tests/e2e-pw/specs/merchant/merchant-disputes-view-details-via-order-notice.spec.ts +++ b/tests/e2e-pw/specs/merchant/merchant-disputes-view-details-via-order-notice.spec.ts @@ -74,9 +74,10 @@ test.describe( ).toBeVisible(); // Visual regression test for the dispute notice. - await expect( - merchantPage.locator( '.dispute-notice' ) - ).toHaveScreenshot(); + // TODO: This visual regression test is not flaky, but we should revisit the approach. + // await expect( + // merchantPage.locator( '.dispute-notice' ) + // ).toHaveScreenshot(); } ); } ); diff --git a/tests/e2e-pw/specs/merchant/multi-currency-on-boarding.spec.ts b/tests/e2e-pw/specs/merchant/multi-currency-on-boarding.spec.ts index f987ae71d8e..ccf557c00a6 100644 --- a/tests/e2e-pw/specs/merchant/multi-currency-on-boarding.spec.ts +++ b/tests/e2e-pw/specs/merchant/multi-currency-on-boarding.spec.ts @@ -58,11 +58,12 @@ test.describe( 'Multi-currency on-boarding', () => { test( 'should disable the submit button when no currencies are selected', async () => { // To take a better screenshot of the component. await page.setViewportSize( { width: 1280, height: 2000 } ); - await expect( - page.locator( - '.multi-currency-setup-wizard > div > .components-card-body' - ) - ).toHaveScreenshot(); + // TODO: fix flaky visual regression test. + // await expect( + // page.locator( + // '.multi-currency-setup-wizard > div > .components-card-body' + // ) + // ).toHaveScreenshot(); // Set the viewport back to the default size. await page.setViewportSize( { width: 1280, height: 720 } ); @@ -168,9 +169,10 @@ test.describe( 'Multi-currency on-boarding', () => { // To take a better screenshot of the iframe preview. await page.setViewportSize( { width: 1280, height: 1280 } ); await goToNextOnboardingStep( page ); - await expect( - page.locator( '.wcpay-wizard-task.is-active' ) - ).toHaveScreenshot(); + // TODO: fix flaky visual regression test. + // await expect( + // page.locator( '.wcpay-wizard-task.is-active' ) + // ).toHaveScreenshot(); await page.getByTestId( 'enable_auto_currency' ).check(); await page.getByRole( 'button', { name: 'Preview' } ).click(); @@ -185,9 +187,10 @@ test.describe( 'Multi-currency on-boarding', () => { await expect( await previewPage.locator( '.woocommerce-store-notice' ) ).toBeVisible(); - await expect( - page.locator( '.multi-currency-store-settings-preview-iframe' ) - ).toHaveScreenshot(); + // TODO: fix flaky visual regression test. + // await expect( + // page.locator( '.multi-currency-store-settings-preview-iframe' ) + // ).toHaveScreenshot(); const noticeText = await previewPage .locator( '.woocommerce-store-notice' ) diff --git a/tests/e2e-pw/specs/merchant/multi-currency.spec.ts b/tests/e2e-pw/specs/merchant/multi-currency.spec.ts index 45b6bf0b89b..3786b4f17f9 100644 --- a/tests/e2e-pw/specs/merchant/multi-currency.spec.ts +++ b/tests/e2e-pw/specs/merchant/multi-currency.spec.ts @@ -41,9 +41,10 @@ test.describe( 'Multi-currency', () => { page.getByRole( 'heading', { name: 'Enabled currencies' } ) ).toBeVisible(); await expect( page.getByText( 'Default currency' ) ).toBeVisible(); - await expect( - page.locator( '.multi-currency-settings' ).last() - ).toHaveScreenshot(); + // TODO: fix flaky visual regression test. + // await expect( + // page.locator( '.multi-currency-settings' ).last() + // ).toHaveScreenshot(); } ); test( 'add the currency switcher to the sidebar', async () => { diff --git a/tests/e2e-pw/specs/merchant/woopay-setup.spec.ts b/tests/e2e-pw/specs/merchant/woopay-setup.spec.ts new file mode 100644 index 00000000000..c36cdb135d0 --- /dev/null +++ b/tests/e2e-pw/specs/merchant/woopay-setup.spec.ts @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { test, Page } from '@playwright/test'; +/** + * Internal dependencies + */ +import { getMerchant } from '../../utils/helpers'; +import { activateWooPay, deactivateWooPay } from '../../utils/merchant'; + +test.describe( 'WooPay setup', () => { + let merchantPage: Page; + let wasWooPayEnabled: boolean; + + test.beforeAll( async ( { browser } ) => { + merchantPage = ( await getMerchant( browser ) ).merchantPage; + wasWooPayEnabled = await activateWooPay( merchantPage ); + } ); + + test.afterAll( async () => { + if ( ! wasWooPayEnabled ) { + await deactivateWooPay( merchantPage ); + } + } ); + + test( 'can disable the WooPay feature', async () => { + await deactivateWooPay( merchantPage ); + } ); + + test( 'can enable the WooPay feature', async () => { + await activateWooPay( merchantPage ); + } ); +} ); diff --git a/tests/e2e-pw/utils/merchant.ts b/tests/e2e-pw/utils/merchant.ts index 2cbefa58615..d5908956359 100644 --- a/tests/e2e-pw/utils/merchant.ts +++ b/tests/e2e-pw/utils/merchant.ts @@ -250,3 +250,31 @@ export const disablePaymentMethods = async ( await saveWooPaymentsSettings( page ); }; + +export const isWooPayEnabled = async ( page: Page ) => { + await navigation.goToWooPaymentsSettings( page ); + + const checkboxTestId = 'woopay-toggle'; + const isEnabled = await page.getByTestId( checkboxTestId ).isChecked(); + + return isEnabled; +}; + +export const activateWooPay = async ( page: Page ) => { + await navigation.goToWooPaymentsSettings( page ); + + const checkboxTestId = 'woopay-toggle'; + const wasInitiallyEnabled = await isWooPayEnabled( page ); + + if ( ! wasInitiallyEnabled ) { + await page.getByTestId( checkboxTestId ).check(); + await saveWooPaymentsSettings( page ); + } + return wasInitiallyEnabled; +}; + +export const deactivateWooPay = async ( page: Page ) => { + await navigation.goToWooPaymentsSettings( page ); + await page.getByTestId( 'woopay-toggle' ).uncheck(); + await saveWooPaymentsSettings( page ); +}; 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/bootstrap.php b/tests/unit/bootstrap.php index 89ef79bfb11..99f99b071c2 100755 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -96,6 +96,8 @@ function () { require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-customer-controller.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-refunds-controller.php'; + require_once $_plugin_dir . 'includes/class-wc-payments-payment-request-button-handler.php'; + // Load currency helper class early to ensure its implementation is used over the one resolved during further test initialization. require_once __DIR__ . '/helpers/class-wc-helper-site-currency.php'; diff --git a/tests/unit/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-express-checkout-button-display-handler.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-display-handler.php similarity index 79% rename from tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php rename to tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-display-handler.php index 0444375d01d..482faafe057 100644 --- a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-display-handler.php @@ -13,7 +13,7 @@ use WCPay\WooPay\WooPay_Utilities; /** - * WC_Payments_WooPay_Button_Handler_Test class. + * WC_Payments_Express_Checkout_Button_Display_Handler_Test class. */ class WC_Payments_Express_Checkout_Button_Display_Handler_Test extends WCPAY_UnitTestCase { /** @@ -37,13 +37,6 @@ class WC_Payments_Express_Checkout_Button_Display_Handler_Test extends WCPAY_Uni */ private $mock_woopay_button_handler; - /** - * Payment Request Button Handler mock instance. - * - * @var WC_Payments_Payment_Request_Button_Handler|MockObject - */ - private $mock_payment_request_button_handler; - /** * WC_Payments_Account instance. * @@ -150,22 +143,6 @@ public function set_up() { ) ->getMock(); - $this->mock_payment_request_button_handler = $this->getMockBuilder( WC_Payments_Payment_Request_Button_Handler::class ) - ->setConstructorArgs( - [ - $this->mock_wcpay_account, - $this->mock_wcpay_gateway, - $this->mock_express_checkout_helper, - ] - ) - ->setMethods( - [ - 'should_show_payment_request_button', - 'is_checkout', - ] - ) - ->getMock(); - $this->mock_express_checkout_ece_button_handler = $this->getMockBuilder( WC_Payments_Express_Checkout_Button_Handler::class ) ->setConstructorArgs( [ @@ -184,7 +161,6 @@ public function set_up() { $this->express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( $this->mock_wcpay_gateway, - $this->mock_payment_request_button_handler, $this->mock_woopay_button_handler, $this->mock_express_checkout_ece_button_handler, $this->mock_express_checkout_ajax_handler, @@ -258,7 +234,7 @@ public function test_display_express_checkout_buttons_all_enabled() { $this->assertStringContainsString( 'wcpay-woopay-button', ob_get_contents() ); $this->assertStringContainsString( 'wcpay-express-checkout-element', ob_get_contents() ); - $this->assertStringNotContainsString( 'wcpay-payment-request-button-separator', ob_get_contents() ); + $this->assertStringNotContainsString( 'wcpay-express-checkout-button-separator', ob_get_contents() ); ob_end_clean(); } @@ -267,10 +243,6 @@ public function test_display_express_checkout_buttons_all_disabled() { ->method( 'should_show_woopay_button' ) ->willReturn( false ); - $this->mock_payment_request_button_handler - ->method( 'should_show_payment_request_button' ) - ->willReturn( false ); - $this->mock_express_checkout_helper ->method( 'is_checkout' ) ->willReturn( false ); @@ -278,7 +250,7 @@ public function test_display_express_checkout_buttons_all_disabled() { ob_start(); $this->express_checkout_button_display_handler->display_express_checkout_buttons(); - $this->assertStringNotContainsString( 'wcpay-payment-request-wrapper', ob_get_contents() ); + $this->assertStringNotContainsString( 'wcpay-express-checkout-wrapper', ob_get_contents() ); ob_end_clean(); } @@ -287,10 +259,6 @@ public function test_display_express_checkout_buttons_only_woopay() { ->method( 'should_show_woopay_button' ) ->willReturn( true ); - $this->mock_payment_request_button_handler - ->method( 'should_show_payment_request_button' ) - ->willReturn( false ); - $this->mock_express_checkout_helper ->method( 'is_checkout' ) ->willReturn( false ); @@ -299,28 +267,7 @@ public function test_display_express_checkout_buttons_only_woopay() { $this->express_checkout_button_display_handler->display_express_checkout_buttons(); $this->assertStringContainsString( 'wcpay-woopay-button', ob_get_contents() ); - $this->assertStringNotContainsString( 'wcpay-payment-request-button-separator', ob_get_contents() ); - ob_end_clean(); - } - - public function test_display_express_checkout_buttons_only_payment_request() { - $this->mock_woopay_button_handler - ->method( 'should_show_woopay_button' ) - ->willReturn( false ); - - $this->mock_payment_request_button_handler - ->method( 'should_show_payment_request_button' ) - ->willReturn( true ); - - $this->mock_express_checkout_helper - ->method( 'is_checkout' ) - ->willReturn( true ); - - ob_start(); - $this->express_checkout_button_display_handler->display_express_checkout_buttons(); - - $this->assertStringContainsString( 'wcpay-express-checkout-button-separator', ob_get_contents() ); - $this->assertStringContainsString( 'display:none;', ob_get_contents() ); + $this->assertStringNotContainsString( 'wcpay-express-checkout-button-separator', ob_get_contents() ); ob_end_clean(); } } diff --git a/tests/unit/test-class-wc-payments-express-checkout-button-helper.php b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php similarity index 99% rename from tests/unit/test-class-wc-payments-express-checkout-button-helper.php rename to tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php index 00176f20d07..bcc4ca69601 100644 --- a/tests/unit/test-class-wc-payments-express-checkout-button-helper.php +++ b/tests/unit/express-checkout/test-class-wc-payments-express-checkout-button-helper.php @@ -11,7 +11,7 @@ use WCPay\Session_Rate_Limiter; /** - * WC_Payments_Payment_Request_Button_Handler_Test class. + * WC_Payments_Express_Checkout_Button_Helper_Test class. */ class WC_Payments_Express_Checkout_Button_Helper_Test extends WCPAY_UnitTestCase { /** 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-features.php b/tests/unit/test-class-wc-payments-features.php index fac4d7b37bc..d30ba6bec2a 100644 --- a/tests/unit/test-class-wc-payments-features.php +++ b/tests/unit/test-class-wc-payments-features.php @@ -29,7 +29,6 @@ class WC_Payments_Features_Test extends WCPAY_UnitTestCase { '_wcpay_feature_customer_multi_currency' => 'multiCurrency', '_wcpay_feature_documents' => 'documents', '_wcpay_feature_auth_and_capture' => 'isAuthAndCaptureEnabled', - '_wcpay_feature_stripe_ece' => 'isStripeEceEnabled', ]; public function set_up() { 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-order-success-page.php b/tests/unit/test-class-wc-payments-order-success-page.php index df92e94a482..849bcd61565 100644 --- a/tests/unit/test-class-wc-payments-order-success-page.php +++ b/tests/unit/test-class-wc-payments-order-success-page.php @@ -22,6 +22,60 @@ public function set_up() { $this->payments_order_success_page = new WC_Payments_Order_Success_Page(); } + public function test_show_card_payment_method_name_without_card_brand() { + $order = WC_Helper_Order::create_order(); + $order->set_payment_method( 'woocommerce_payments' ); + $order->save(); + + $payment_method = $this->getMockBuilder( '\WCPay\Payment_Methods\UPE_Payment_Method' ) + ->disableOriginalConstructor() + ->getMock(); + $payment_method->method( 'get_title' )->willReturn( 'Credit Card' ); + + $result = $this->payments_order_success_page->show_card_payment_method_name( $order, $payment_method ); + + $this->assertEquals( 'Credit Card', $result ); + } + + public function test_show_card_payment_method_name_with_brand_and_last4() { + $order = WC_Helper_Order::create_order(); + $order->add_meta_data( '_card_brand', 'visa' ); + $order->add_meta_data( 'last4', '4242' ); + $order->set_payment_method( 'woocommerce_payments' ); + $order->save(); + + $payment_method = $this->getMockBuilder( '\WCPay\Payment_Methods\UPE_Payment_Method' ) + ->disableOriginalConstructor() + ->getMock(); + $payment_method->method( 'get_title' )->willReturn( 'Credit Card' ); + + $result = $this->payments_order_success_page->show_card_payment_method_name( $order, $payment_method ); + + $this->assertStringContainsString( 'wc-payment-gateway-method-logo-wrapper wc-payment-card-logo', $result ); + $this->assertStringContainsString( 'img alt="Credit Card"', $result ); + $this->assertStringContainsString( 'visa.svg', $result ); + $this->assertStringContainsString( '4242', $result ); + } + + public function test_show_card_payment_method_name_with_brand_only() { + $order = WC_Helper_Order::create_order(); + $order->add_meta_data( '_card_brand', 'mastercard' ); + $order->set_payment_method( 'woocommerce_payments' ); + $order->save(); + + $payment_method = $this->getMockBuilder( '\WCPay\Payment_Methods\UPE_Payment_Method' ) + ->disableOriginalConstructor() + ->getMock(); + $payment_method->method( 'get_title' )->willReturn( 'Credit Card' ); + + $result = $this->payments_order_success_page->show_card_payment_method_name( $order, $payment_method ); + + $this->assertStringContainsString( 'wc-payment-gateway-method-logo-wrapper wc-payment-card-logo', $result ); + $this->assertStringContainsString( 'img alt="Credit Card"', $result ); + $this->assertStringContainsString( 'mastercard.svg', $result ); + $this->assertStringNotContainsString( '•••', $result ); + } + public function test_show_woopay_payment_method_name_empty_order() { $method_name = 'Credit card'; $result = $this->payments_order_success_page->show_woocommerce_payments_payment_method_name( $method_name, null ); 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 117f254f5ff..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 @@ -13,6 +13,7 @@ /** * WC_Payments_Payment_Request_Button_Handler_Test class. + * @deprecated We'll delete this as part of https://github.com/Automattic/woocommerce-payments/issues/9722 . */ class WC_Payments_Payment_Request_Button_Handler_Test extends WCPAY_UnitTestCase { const SHIPPING_ADDRESS = [ @@ -157,7 +158,6 @@ function () { WC()->session->init(); WC()->cart->add_to_cart( $this->simple_product->get_id(), 1 ); - $this->pr->update_shipping_method( [ self::get_shipping_option_rate_id( $this->flat_rate_id ) ] ); WC()->cart->calculate_totals(); } @@ -245,24 +245,6 @@ private static function set_shipping_method_cost( $instance_id, $cost ) { update_option( $option_key, $options ); } - /** - * Composes shipping option object by shipping method instance id. - * - * @param string $instance_id Shipping method instance id. - * - * @return array Shipping option. - */ - private static function get_shipping_option( $instance_id ) { - $method = WC_Shipping_Zones::get_shipping_method( $instance_id ); - - return [ - 'id' => $method->get_rate_id(), - 'label' => $method->title, - 'detail' => '', - 'amount' => WC_Payments_Utils::prepare_amount( $method->get_instance_option( 'cost' ) ), - ]; - } - /** * Retrieves rate id by shipping method instance id. * @@ -276,185 +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_get_shipping_options_returns_shipping_options() { - $data = $this->pr->get_shipping_options( self::SHIPPING_ADDRESS ); - - $expected_shipping_options = array_map( - 'self::get_shipping_option', - [ $this->flat_rate_id, $this->local_pickup_id ] - ); - - $this->assertEquals( 'success', $data['result'] ); - $this->assertEquals( $expected_shipping_options, $data['shipping_options'], 'Shipping options mismatch' ); - } - - public function test_get_shipping_options_returns_chosen_option() { - $data = $this->pr->get_shipping_options( self::SHIPPING_ADDRESS ); - - $flat_rate = $this->get_shipping_option( $this->flat_rate_id ); - $expected_display_items = [ - [ - 'label' => 'Shipping', - 'amount' => $flat_rate['amount'], - 'key' => 'total_shipping', - ], - ]; - - $this->assertEquals( 1500, $data['total']['amount'], 'Total amount mismatch' ); - $this->assertEquals( $expected_display_items, $data['displayItems'], 'Display items mismatch' ); - } - - public function test_get_shipping_options_keeps_chosen_option() { - $method_id = self::get_shipping_option_rate_id( $this->local_pickup_id ); - $this->pr->update_shipping_method( [ $method_id ] ); - - $data = $this->pr->get_shipping_options( self::SHIPPING_ADDRESS ); - - $expected_shipping_options = array_map( - 'self::get_shipping_option', - [ $this->local_pickup_id, $this->flat_rate_id ] - ); - - $this->assertEquals( 'success', $data['result'] ); - $this->assertEquals( $expected_shipping_options, $data['shipping_options'], 'Shipping options mismatch' ); - } - public function test_multiple_packages_in_cart_not_allowed() { // Add fake packages to the cart. add_filter( @@ -755,10 +558,6 @@ public function test_get_product_data_returns_the_same_as_build_display_items_wi ->method( 'get_product' ) ->willReturn( $this->simple_product ); - $mock_pr = $this->getMockBuilder( WC_Payments_Payment_Request_Button_Handler::class ) - ->setConstructorArgs( [ $this->mock_wcpay_account, $this->mock_wcpay_gateway, $this->express_checkout_helper ] ) - ->getMock(); - $get_product_data_result = $this->pr->get_product_data(); foreach ( $get_product_data_result['displayItems'] as $key => $display_item ) { 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/webpack/shared.js b/webpack/shared.js index 7fe200a9f7a..2dce99ca3ec 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -20,9 +20,8 @@ module.exports = { cart: './client/cart/index.js', checkout: './client/checkout/classic/event-handlers.js', 'express-checkout': './client/express-checkout/index.js', - 'payment-request': './client/payment-request/index.js', - 'tokenized-payment-request': - './client/tokenized-payment-request/index.js', + 'tokenized-express-checkout': + './client/tokenized-express-checkout/index.js', 'subscription-edit-page': './client/subscription-edit-page.js', tos: './client/tos/index.js', 'payment-gateways': './client/payment-gateways/index.js', diff --git a/woocommerce-payments.php b/woocommerce-payments.php index f57ce075405..19012d26053 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.1 + * Version: 8.6.0 * Requires Plugins: woocommerce * * @package WooCommerce\Payments @@ -154,7 +154,7 @@ function wcpay_init() { * Check https://github.com/Automattic/woocommerce-payments/issues/4759 */ \WCPay\WooPay\WooPay_Session::init(); - if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() ) { + if ( WC_Payments_Features::is_tokenized_cart_ece_enabled() ) { ( new WC_Payments_Payment_Request_Session() )->init(); } }