From aa9c09583eb749dc60c001275dbf033485fc41f4 Mon Sep 17 00:00:00 2001 From: Hsing-yu Flowers Date: Mon, 27 May 2024 19:53:53 -0400 Subject: [PATCH 01/52] Add order id mismatch exception (#8855) --- changelog/add-order-id-mismatch-exception | 4 +++ includes/class-wc-payment-gateway-wcpay.php | 14 ++++++---- includes/class-wc-payments.php | 1 + .../class-order-id-mismatch-exception.php | 16 ++++++++++++ .../test-class-wc-payment-gateway-wcpay.php | 26 +++++++++++++++++++ 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 changelog/add-order-id-mismatch-exception create mode 100644 includes/exceptions/class-order-id-mismatch-exception.php diff --git a/changelog/add-order-id-mismatch-exception b/changelog/add-order-id-mismatch-exception new file mode 100644 index 00000000000..22938c21145 --- /dev/null +++ b/changelog/add-order-id-mismatch-exception @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Add Order_ID_Mismatch_Exception diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index f20c1b15f1b..fdb12b247e3 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -18,7 +18,7 @@ use WCPay\Constants\Intent_Status; use WCPay\Constants\Payment_Type; use WCPay\Constants\Payment_Method; -use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception }; +use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception, Order_ID_Mismatch_Exception, Order_Not_Found_Exception }; use WCPay\Core\Server\Request\Cancel_Intention; use WCPay\Core\Server\Request\Capture_Intention; use WCPay\Core\Server\Request\Create_And_Confirm_Intention; @@ -1409,9 +1409,12 @@ public function update_saved_payment_method( $payment_method, $order_id, $is_tes * @param WCPay\Payment_Information $payment_information Payment info. * @param bool $scheduled_subscription_payment Used to determinate is scheduled subscription payment to add more fields into API request. * - * @return array|null An array with result of payment and redirect URL, or nothing. + * @return array|null An array with result of payment and redirect URL, or nothing. * @throws API_Exception - * @throws Intent_Authentication_Exception When the payment intent could not be authenticated. + * @throws Exception When amount too small. + * @throws Invalid_Address_Exception + * @throws Order_Not_Found_Exception + * @throws Order_ID_Mismatch_Exception When the payment intent could not be authenticated. * @throws \WCPay\Core\Exceptions\Server\Request\Extend_Request_Exception When request class filter filed to extend request class because of incompatibility. * @throws \WCPay\Core\Exceptions\Server\Request\Immutable_Parameter_Exception When immutable parameter gets changed in request class. * @throws \WCPay\Core\Exceptions\Server\Request\Invalid_Request_Parameter_Exception When you send incorrect request value via setters. @@ -1514,7 +1517,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $intent_meta_order_id_raw = $intent->get_metadata()['order_id'] ?? ''; $intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0; if ( $intent_meta_order_id !== $order_id ) { - throw new Intent_Authentication_Exception( + throw new Order_ID_Mismatch_Exception( sprintf( /* translators: %s: metadata. We do not need to translate WooPayMeta */ esc_html( __( 'We\'re not able to process this payment. Please try again later. WooPayMeta: intent_meta_order_id: %1$s, order_id: %2$s', 'woocommerce-payments' ) ), @@ -1625,7 +1628,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0; if ( $intent_meta_order_id !== $order_id ) { - throw new Intent_Authentication_Exception( + throw new Order_ID_Mismatch_Exception( __( "We're not able to process this payment. Please try again later.", 'woocommerce-payments' ), 'order_id_mismatch' ); @@ -4487,6 +4490,7 @@ private function handle_afterpay_shipping_requirement( WC_Order $order, Create_A * @param Create_And_Confirm_Intention $request The request object for creating and confirming intention. * @param Payment_Information $payment_information The payment information object. * @param WC_Order $order The order object. + * @throws Invalid_Address_Exception * * @return void */ diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 63e8b079157..3a70e64758f 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -429,6 +429,7 @@ public static function init() { include_once __DIR__ . '/exceptions/class-fraud-ruleset-exception.php'; include_once __DIR__ . '/exceptions/class-fraud-prevention-enabled-exception.php'; include_once __DIR__ . '/exceptions/class-order-not-found-exception.php'; + include_once __DIR__ . '/exceptions/class-order-id-mismatch-exception.php'; include_once __DIR__ . '/exceptions/class-rate-limiter-enabled-exception.php'; include_once __DIR__ . '/constants/class-base-constant.php'; include_once __DIR__ . '/constants/class-country-code.php'; diff --git a/includes/exceptions/class-order-id-mismatch-exception.php b/includes/exceptions/class-order-id-mismatch-exception.php new file mode 100644 index 00000000000..77f9519a581 --- /dev/null +++ b/includes/exceptions/class-order-id-mismatch-exception.php @@ -0,0 +1,16 @@ +card_gateway->process_payment_for_order( WC()->cart, $pi ); } + public function test_process_payment_for_order_rejects_with_order_id_mismatch() { + $order = WC_Helper_Order::create_order(); + $intent_meta_order_id = 0; + $woopay_intent_id = 'woopay_invalid_intent_id_mock'; + $payment_intent = WC_Helper_Intention::create_intention( + [ + 'status' => 'success', + 'metadata' => [ 'order_id' => (string) $intent_meta_order_id ], + ] + ); + + $_POST['platform-checkout-intent'] = $woopay_intent_id; + + $payment_information = new Payment_Information( 'pm_test', $order, null, null, null, null, null, '', 'card' ); + + $this->mock_wcpay_request( Get_Intention::class, 1, $woopay_intent_id ) + ->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $payment_intent ); + + $this->expectException( 'WCPay\Exceptions\Order_ID_Mismatch_Exception' ); + $this->expectExceptionMessage( 'We're not able to process this payment. Please try again later. WooPayMeta: intent_meta_order_id: ' . $intent_meta_order_id . ', order_id: ' . $order->get_id() ); + $this->card_gateway->process_payment_for_order( WC()->cart, $payment_information ); + } + public function test_set_mandate_data_to_payment_intent_if_not_required() { $payment_method = 'woocommerce_payments_sepa_debit'; $order = WC_Helper_Order::create_order(); From 68b65c1f9ad2211c017a4a561dc6b515c2011cb4 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 28 May 2024 21:09:11 +0300 Subject: [PATCH 02/52] chore: update incompatibility notice wrapping (#8866) Co-authored-by: Brett Shumaker --- changelog/chore-incompatibility-notice-wrapping | 4 ++++ client/settings/settings-warnings/incompatibility-notice.js | 4 +--- client/settings/settings-warnings/style.scss | 5 ----- 3 files changed, 5 insertions(+), 8 deletions(-) create mode 100644 changelog/chore-incompatibility-notice-wrapping delete mode 100644 client/settings/settings-warnings/style.scss diff --git a/changelog/chore-incompatibility-notice-wrapping b/changelog/chore-incompatibility-notice-wrapping new file mode 100644 index 00000000000..cad615b4fd1 --- /dev/null +++ b/changelog/chore-incompatibility-notice-wrapping @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +chore: update incompatibility notice wrapping diff --git a/client/settings/settings-warnings/incompatibility-notice.js b/client/settings/settings-warnings/incompatibility-notice.js index 52d9d07f098..c3a388f8df6 100644 --- a/client/settings/settings-warnings/incompatibility-notice.js +++ b/client/settings/settings-warnings/incompatibility-notice.js @@ -7,13 +7,11 @@ import interpolateComponents from '@automattic/interpolate-components'; /** * Internal dependencies */ -import './style.scss'; import InlineNotice from 'wcpay/components/inline-notice'; const IncompatibilityNotice = ( { message, learnMoreLinkHref } ) => ( - { message } -
+ { message }{ ' ' } { interpolateComponents( { mixedString: __( '{{learnMoreLink}}Learn More{{/learnMoreLink}}', diff --git a/client/settings/settings-warnings/style.scss b/client/settings/settings-warnings/style.scss deleted file mode 100644 index e401acb1e5e..00000000000 --- a/client/settings/settings-warnings/style.scss +++ /dev/null @@ -1,5 +0,0 @@ -.components-notice { - &__content { - display: flex; - } -} From 8481984317dbad3195a598c672d316e42af195bd Mon Sep 17 00:00:00 2001 From: Ricardo Metring Date: Wed, 29 May 2024 09:06:32 +0200 Subject: [PATCH 03/52] Fix "Pay for order" infinite loading (#8886) --- changelog/fix-8883-pay-for-order-blocked-ui | 4 +++ client/checkout/classic/payment-processing.js | 35 ++++++++++++------- .../classic/test/payment-processing.test.js | 10 ++++++ 3 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 changelog/fix-8883-pay-for-order-blocked-ui diff --git a/changelog/fix-8883-pay-for-order-blocked-ui b/changelog/fix-8883-pay-for-order-blocked-ui new file mode 100644 index 00000000000..b74ba9e179f --- /dev/null +++ b/changelog/fix-8883-pay-for-order-blocked-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix "Pay for order" infinite loading when submitting form without payment details. diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index 74ab31c7e36..204a84adc10 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ @@ -63,8 +68,8 @@ async function initializeAppearance( api ) { * * @param {Object} $form The jQuery object for the form. */ -export function blockUI( $form ) { - $form.addClass( 'processing' ).block( { +export async function blockUI( $form ) { + await $form.addClass( 'processing' ).block( { message: null, overlayCSS: { background: '#fff', @@ -515,18 +520,24 @@ export const processPayment = ( return; } - const { elements, isPaymentInformationComplete } = gatewayUPEComponents[ - paymentMethodType - ]; - if ( ! isPaymentInformationComplete ) { - showErrorCheckout( 'Your payment information is incomplete.' ); - return false; - } - - blockUI( $form ); - ( async () => { try { + await blockUI( $form ); + + const { + elements, + isPaymentInformationComplete, + } = gatewayUPEComponents[ paymentMethodType ]; + + if ( ! isPaymentInformationComplete ) { + throw new Error( + __( + 'Your payment information is incomplete.', + 'woocommerce-payments' + ) + ); + } + await validateElements( elements ); const paymentMethodObject = await createStripePaymentMethod( api, diff --git a/client/checkout/classic/test/payment-processing.test.js b/client/checkout/classic/test/payment-processing.test.js index 0dd6a9ef2a4..ad8a24315b9 100644 --- a/client/checkout/classic/test/payment-processing.test.js +++ b/client/checkout/classic/test/payment-processing.test.js @@ -457,6 +457,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -499,6 +501,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -537,6 +541,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -572,6 +578,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, checkoutForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), @@ -605,6 +613,8 @@ describe( 'Payment processing', () => { }; await processPayment( apiMock, addPaymentMethodForm, 'card' ); + // Wait for promises to resolve. + await new Promise( ( resolve ) => setImmediate( resolve ) ); expect( mockCreatePaymentMethod ).toHaveBeenCalledWith( { elements: expect.any( Object ), From b6fb6b5ee140dfc6049f19582b6b7a179c392d64 Mon Sep 17 00:00:00 2001 From: Daniel Mallory Date: Wed, 29 May 2024 09:18:00 +0100 Subject: [PATCH 04/52] Update to not open in new tab if onboarding not complete (#8859) Co-authored-by: Vlad Olaru --- changelog/dev-finish-setup-cta | 4 ++++ .../components/account-status/account-tools/index.tsx | 1 - .../test/__snapshots__/index.test.tsx.snap | 2 -- .../account-status/test/__snapshots__/index.js.snap | 1 - .../task-list/tasks/update-business-details-task.tsx | 10 +++++++++- 5 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 changelog/dev-finish-setup-cta diff --git a/changelog/dev-finish-setup-cta b/changelog/dev-finish-setup-cta new file mode 100644 index 00000000000..0902b7fd4c6 --- /dev/null +++ b/changelog/dev-finish-setup-cta @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fixes some cases where redirects to the onboarding will open in a new tab. diff --git a/client/components/account-status/account-tools/index.tsx b/client/components/account-status/account-tools/index.tsx index e417d4349ed..34886cc95c3 100644 --- a/client/components/account-status/account-tools/index.tsx +++ b/client/components/account-status/account-tools/index.tsx @@ -56,7 +56,6 @@ export const AccountTools: React.FC< Props > = ( props: Props ) => { ) } href={ accountLink } - target={ '_blank' } > { strings.finish } diff --git a/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap b/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap index b5b1f5eb8fc..988d1e2d7fe 100644 --- a/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap +++ b/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap @@ -24,7 +24,6 @@ exports[`AccountTools should render in live mode 1`] = ` Finish setup @@ -63,7 +62,6 @@ exports[`AccountTools should render in sandbox mode 1`] = ` Finish setup diff --git a/client/components/account-status/test/__snapshots__/index.js.snap b/client/components/account-status/test/__snapshots__/index.js.snap index 97a0ae5fc41..8c3367a63fc 100644 --- a/client/components/account-status/test/__snapshots__/index.js.snap +++ b/client/components/account-status/test/__snapshots__/index.js.snap @@ -189,7 +189,6 @@ exports[`AccountStatus renders normal status 1`] = ` Finish setup diff --git a/client/overview/task-list/tasks/update-business-details-task.tsx b/client/overview/task-list/tasks/update-business-details-task.tsx index 3d1061311be..2de66b9890f 100644 --- a/client/overview/task-list/tasks/update-business-details-task.tsx +++ b/client/overview/task-list/tasks/update-business-details-task.tsx @@ -27,6 +27,7 @@ export const getUpdateBusinessDetailsTask = ( const accountDetailsPastDue = 'restricted' === status && pastDue; const hasMultipleErrors = 1 < errorMessages.length; const hasSingleError = 1 === errorMessages.length; + const connectUrl = wcpaySettings.connectUrl; const accountLinkWithSource = addQueryArgs( accountLink, { source: 'overview-page__update-business-details-task', } ); @@ -113,7 +114,14 @@ export const getUpdateBusinessDetailsTask = ( recordEvent( 'wcpay_account_details_link_clicked', { source: 'overview-page__update-business-details-task', } ); - window.open( accountLinkWithSource, '_blank' ); + + // If the onboarding isn't complete use the connectUrl instead, + // as the accountLink doesn't handle redirecting back to the overview page. + if ( ! detailsSubmitted ) { + window.location.href = connectUrl; + } else { + window.open( accountLinkWithSource, '_blank' ); + } } }; From 9196a8dfd592323ea41450c60681af894a0e78f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3fer=20Reykjal=C3=ADn?= <13835680+reykjalin@users.noreply.github.com> Date: Wed, 29 May 2024 13:16:13 -0400 Subject: [PATCH 05/52] Prevent WooPay button from submitting the checkout forms when the customer presses "enter" on their keyboard (#8885) --- changelog/fix-woopay-button-submit-when-enter-pressed | 4 ++++ .../woopay/express-button/woopay-express-checkout-button.js | 1 + 2 files changed, 5 insertions(+) create mode 100644 changelog/fix-woopay-button-submit-when-enter-pressed diff --git a/changelog/fix-woopay-button-submit-when-enter-pressed b/changelog/fix-woopay-button-submit-when-enter-pressed new file mode 100644 index 00000000000..0b89f8eed90 --- /dev/null +++ b/changelog/fix-woopay-button-submit-when-enter-pressed @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Make it so that the WooPay button is not triggered on Checkout pages when the "Enter" key is pressed on a keyboard. diff --git a/client/checkout/woopay/express-button/woopay-express-checkout-button.js b/client/checkout/woopay/express-button/woopay-express-checkout-button.js index 2220a1422e6..00b8a38b2c4 100644 --- a/client/checkout/woopay/express-button/woopay-express-checkout-button.js +++ b/client/checkout/woopay/express-button/woopay-express-checkout-button.js @@ -346,6 +346,7 @@ export const WoopayExpressCheckoutButton = ( { data-width-type={ buttonWidthType } style={ { height: `${ height }px` } } disabled={ isLoading } + type="button" > { isLoading ? ( From 1bd1b1d253cc8e5a7d3f57e4686abb5c6c7c1a91 Mon Sep 17 00:00:00 2001 From: Hsing-yu Flowers Date: Wed, 29 May 2024 14:38:20 -0400 Subject: [PATCH 06/52] Add new process payment exception (#8834) --- changelog/add-new-process-payment-exception | 4 +++ includes/class-wc-payment-gateway-wcpay.php | 15 +++++++--- includes/class-wc-payments.php | 1 + .../class-new-process-payment-exception.php | 16 ++++++++++ .../test-class-wc-payment-gateway-wcpay.php | 29 +++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 changelog/add-new-process-payment-exception create mode 100644 includes/exceptions/class-new-process-payment-exception.php diff --git a/changelog/add-new-process-payment-exception b/changelog/add-new-process-payment-exception new file mode 100644 index 00000000000..f2ac469fefc --- /dev/null +++ b/changelog/add-new-process-payment-exception @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Add New_Process_Payment_Exception diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index fdb12b247e3..0ca27dd3788 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -18,7 +18,7 @@ use WCPay\Constants\Intent_Status; use WCPay\Constants\Payment_Type; use WCPay\Constants\Payment_Method; -use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception, Order_ID_Mismatch_Exception, Order_Not_Found_Exception }; +use WCPay\Exceptions\{ Add_Payment_Method_Exception, Amount_Too_Small_Exception, Process_Payment_Exception, Intent_Authentication_Exception, API_Exception, Invalid_Address_Exception, Fraud_Prevention_Enabled_Exception, Invalid_Phone_Number_Exception, Rate_Limiter_Enabled_Exception, Order_ID_Mismatch_Exception, Order_Not_Found_Exception, New_Process_Payment_Exception }; use WCPay\Core\Server\Request\Cancel_Intention; use WCPay\Core\Server\Request\Capture_Intention; use WCPay\Core\Server\Request\Create_And_Confirm_Intention; @@ -1072,8 +1072,8 @@ function_exists( 'wcs_order_contains_subscription' ) * and if the answer is yes, uses it and returns the result. * * @param WC_Order $order Order that needs payment. - * @return array|null Array if processed, null if the new process is not supported. - * @throws Exception If the payment process could not be completed. + * @return array|null Array if processed, null if the new process is not supported. + * @throws Exception Error processing the payment. */ public function new_process_payment( WC_Order $order ) { $manual_capture = $this->get_capture_type() === Payment_Capture_Type::MANUAL(); @@ -1119,7 +1119,14 @@ public function new_process_payment( WC_Order $order ) { ]; } - throw new Exception( __( 'The payment process could not be completed.', 'woocommerce-payments' ) ); + throw new Exception( + __( 'The payment process could not be completed.', 'woocommerce-payments' ), + 0, + new New_Process_Payment_Exception( + __( 'The payment process could not be completed.', 'woocommerce-payments' ), + 'new_process_payment' + ) + ); } /** diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 3a70e64758f..c459d0cefec 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -428,6 +428,7 @@ public static function init() { include_once __DIR__ . '/exceptions/class-invalid-price-exception.php'; include_once __DIR__ . '/exceptions/class-fraud-ruleset-exception.php'; include_once __DIR__ . '/exceptions/class-fraud-prevention-enabled-exception.php'; + include_once __DIR__ . '/exceptions/class-new-process-payment-exception.php'; include_once __DIR__ . '/exceptions/class-order-not-found-exception.php'; include_once __DIR__ . '/exceptions/class-order-id-mismatch-exception.php'; include_once __DIR__ . '/exceptions/class-rate-limiter-enabled-exception.php'; diff --git a/includes/exceptions/class-new-process-payment-exception.php b/includes/exceptions/class-new-process-payment-exception.php new file mode 100644 index 00000000000..742c1415b98 --- /dev/null +++ b/includes/exceptions/class-new-process-payment-exception.php @@ -0,0 +1,16 @@ +dev(); + + $mock_service = $this->createMock( PaymentProcessingService::class ); + $mock_router = $this->createMock( Router::class ); + $order = WC_Helper_Order::create_order(); + $mock_state = $this->createMock( PaymentErrorState::class ); + + wcpay_get_test_container()->replace( PaymentProcessingService::class, $mock_service ); + wcpay_get_test_container()->replace( Router::class, $mock_router ); + + $mock_router->expects( $this->once() ) + ->method( 'should_use_new_payment_process' ) + ->willReturn( true ); + + // Assert: The new service is called. + $mock_service->expects( $this->once() ) + ->method( 'process_payment' ) + ->with( $order->get_id() ) + ->willReturn( $mock_state ); + + $this->expectException( Exception::class ); + $this->expectExceptionMessage( 'The payment process could not be completed.' ); + + $this->card_gateway->process_payment( $order->get_id() ); + } + public function test_process_payment_rate_limiter_enabled_throw_exception() { $order = WC_Helper_Order::create_order(); From 08f4852564a41fe1391c93e271efdd3e09557547 Mon Sep 17 00:00:00 2001 From: Francesco Date: Mon, 3 Jun 2024 13:11:54 +0300 Subject: [PATCH 07/52] fix: Store API tokenized cart PM title (#8842) --- .../fix-tokenized-cart-payment-method-title | 4 ++ ...ayments-payment-request-button-handler.php | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 changelog/fix-tokenized-cart-payment-method-title diff --git a/changelog/fix-tokenized-cart-payment-method-title b/changelog/fix-tokenized-cart-payment-method-title new file mode 100644 index 00000000000..831ba5e453c --- /dev/null +++ b/changelog/fix-tokenized-cart-payment-method-title @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: Store API tokenized cart payment method title diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index f89fdf39e2e..fd7de809e9a 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -110,6 +110,7 @@ public function init() { add_filter( 'pre_option_wcpay_is_apple_pay_enabled', [ $this, 'get_option_is_apple_pay_enabled' ], 10, 1 ); if ( WC_Payments_Features::is_tokenized_cart_prb_enabled() ) { + add_action( 'woocommerce_store_api_checkout_update_order_from_request', [ $this, 'tokenized_cart_set_payment_method_type' ], 10, 2 ); add_filter( 'rest_pre_dispatch', [ $this, 'tokenized_cart_store_api_address_normalization' ], 10, 3 ); add_filter( 'rest_pre_dispatch', [ $this, 'tokenized_cart_store_api_nonce_overwrite' ], 10, 3 ); add_filter( @@ -121,6 +122,46 @@ public function init() { } } + /** + * Updates the checkout order based on the request, to set the Apple Pay/Google Pay payment method title. + * + * @param \WC_Order $order The order to be updated. + * @param \WP_REST_Request $request Store API request to update the order. + */ + public function tokenized_cart_set_payment_method_type( \WC_Order $order, \WP_REST_Request $request ) { + if ( ! isset( $request['payment_method'] ) || 'woocommerce_payments' !== $request['payment_method'] ) { + return; + } + + if ( empty( $request['payment_data'] ) ) { + return; + } + + $payment_data = []; + foreach ( $request['payment_data'] as $data ) { + $payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] ); + } + + if ( empty( $payment_data['payment_request_type'] ) ) { + return; + } + + $payment_request_type = wc_clean( wp_unslash( $payment_data['payment_request_type'] ) ); + + $payment_method_titles = [ + 'apple_pay' => 'Apple Pay', + 'google_pay' => 'Google Pay', + ]; + + $suffix = apply_filters( 'wcpay_payment_request_payment_method_title_suffix', 'WooPayments' ); + if ( ! empty( $suffix ) ) { + $suffix = " ($suffix)"; + } + + $payment_method_title = isset( $payment_method_titles[ $payment_request_type ] ) ? $payment_method_titles[ $payment_request_type ] : 'Payment Request'; + $order->set_payment_method_title( $payment_method_title . $suffix ); + } + /** * The nonce supplied by the frontend can be overwritten in this middleware: * https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/assets/js/middleware/store-api-nonce.js From ef195c03b6c8e64b923e1ffc509024e0c881decf Mon Sep 17 00:00:00 2001 From: Rafael Zaleski Date: Mon, 3 Jun 2024 10:45:09 -0300 Subject: [PATCH 08/52] Add payment processing using ECE on the Blocks checkout and cart pages (#8884) --- .../add-8773-ece-support-blocks-checkout-page | 4 + client/checkout/api/index.js | 33 + client/checkout/blocks/index.js | 7 +- .../components/express-checkout-component.js | 52 ++ .../components/express-checkout-container.js | 28 + .../blocks/express-checkout.js | 36 - .../blocks/hooks/use-express-checkout.js | 93 ++ client/express-checkout/blocks/index.js | 9 +- client/express-checkout/event-handlers.js | 61 ++ client/express-checkout/utils/index.js | 1 + client/express-checkout/utils/normalize.js | 101 ++ client/utils/express-checkout/index.js | 39 +- includes/class-wc-payment-gateway-wcpay.php | 6 +- includes/class-wc-payments.php | 11 +- ...payments-express-checkout-ajax-handler.php | 195 ++++ ...xpress-checkout-button-display-handler.php | 26 +- ...yments-express-checkout-button-handler.php | 400 +------- ...ayments-express-checkout-button-helper.php | 859 ++++++++++++++++-- .../services/class-checkout-service.php | 2 +- src/Internal/Payment/Factor.php | 7 + .../unit/src/Internal/Payment/FactorTest.php | 1 + ...xpress-checkout-button-display-handler.php | 33 +- 22 files changed, 1456 insertions(+), 548 deletions(-) create mode 100644 changelog/add-8773-ece-support-blocks-checkout-page create mode 100644 client/express-checkout/blocks/components/express-checkout-component.js create mode 100644 client/express-checkout/blocks/components/express-checkout-container.js delete mode 100644 client/express-checkout/blocks/express-checkout.js create mode 100644 client/express-checkout/blocks/hooks/use-express-checkout.js create mode 100644 client/express-checkout/event-handlers.js create mode 100644 client/express-checkout/utils/index.js create mode 100644 client/express-checkout/utils/normalize.js create mode 100644 includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php diff --git a/changelog/add-8773-ece-support-blocks-checkout-page b/changelog/add-8773-ece-support-blocks-checkout-page new file mode 100644 index 00000000000..1e8174e125c --- /dev/null +++ b/changelog/add-8773-ece-support-blocks-checkout-page @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add payment processing using ECE in the Blocks checkout and cart pages. diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index 6a088a9c5c1..3775aa7f394 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -8,6 +8,8 @@ import { getPaymentRequestData, getPaymentRequestAjaxURL, buildAjaxURL, + getExpressCheckoutAjaxURL, + getExpressCheckoutConfig, } from 'utils/express-checkout'; /** @@ -406,6 +408,37 @@ export default class WCPayAPI { } ); } + /** + * Submits shipping address to get available shipping options + * from Express Checkout ECE payment method. + * + * @param {Object} shippingAddress Shipping details. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECalculateShippingOptions( shippingAddress ) { + return this.request( + getExpressCheckoutAjaxURL( 'get_shipping_options' ), + { + security: getExpressCheckoutConfig( 'nonce' )?.shipping, + is_product_page: getExpressCheckoutConfig( 'is_product_page' ), + ...shippingAddress, + } + ); + } + + /** + * Creates order based on Express Checkout ECE payment method. + * + * @param {Object} paymentData Order data. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECreateOrder( paymentData ) { + return this.request( getExpressCheckoutAjaxURL( 'create_order' ), { + _wpnonce: getExpressCheckoutConfig( 'nonce' )?.checkout, + ...paymentData, + } ); + } + initWooPay( userEmail, woopayUserSession ) { if ( ! this.isWooPayRequesting ) { this.isWooPayRequesting = true; diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 0e26e9ed4b1..9f858acd87d 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -154,8 +154,11 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } } -registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); -registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { + registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +} else { + registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); +} window.addEventListener( 'load', () => { enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); addCheckoutTracking(); diff --git a/client/express-checkout/blocks/components/express-checkout-component.js b/client/express-checkout/blocks/components/express-checkout-component.js new file mode 100644 index 00000000000..a8bbc56ba4b --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-component.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; +import { shippingAddressChangeHandler } from '../../event-handlers'; +import { useExpressCheckout } from '../hooks/use-express-checkout'; + +/** + * ExpressCheckout express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +const ExpressCheckoutComponent = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, +} ) => { + const { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + } = useExpressCheckout( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, + } ); + + const onShippingAddressChange = ( event ) => { + shippingAddressChangeHandler( api, event ); + }; + + return ( + + ); +}; + +export default ExpressCheckoutComponent; diff --git a/client/express-checkout/blocks/components/express-checkout-container.js b/client/express-checkout/blocks/components/express-checkout-container.js new file mode 100644 index 00000000000..e25960c03cd --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-container.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { Elements } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import ExpressCheckoutComponent from './express-checkout-component'; + +const ExpressCheckoutContainer = ( props ) => { + const { stripe, billing } = props; + + const options = { + mode: 'payment', + paymentMethodCreation: 'manual', + amount: billing.cartTotal.value, + currency: billing.currency.code.toLowerCase(), + }; + + return ( + + + + ); +}; + +export default ExpressCheckoutContainer; diff --git a/client/express-checkout/blocks/express-checkout.js b/client/express-checkout/blocks/express-checkout.js deleted file mode 100644 index 5b4c47658bd..00000000000 --- a/client/express-checkout/blocks/express-checkout.js +++ /dev/null @@ -1,36 +0,0 @@ -/* global wcpayExpressCheckoutParams */ - -/** - * External dependencies - */ -import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; - -/** - * ExpressCheckout express payment method component. - * - * @param {Object} props PaymentMethodProps. - * - * @return {ReactNode} Stripe Elements component. - */ -export const ExpressCheckout = ( props ) => { - const { stripe } = props; - - const options = { - mode: 'payment', - amount: 1099, - currency: 'usd', - }; - - const buttonOptions = { - buttonType: { - googlePay: wcpayExpressCheckoutParams.button.type, - applePay: wcpayExpressCheckoutParams.button.type, - }, - }; - - return ( - - - - ); -}; diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js new file mode 100644 index 00000000000..67dc33cc489 --- /dev/null +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -0,0 +1,93 @@ +/* global wcpayExpressCheckoutParams */ + +/** + * External dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useStripe, useElements } from '@stripe/react-stripe-js'; +import { normalizeLineItems } from 'wcpay/express-checkout/utils'; +import { onConfirmHandler } from 'wcpay/express-checkout/event-handlers'; + +export const useExpressCheckout = ( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, +} ) => { + const stripe = useStripe(); + const elements = useElements(); + + const buttonOptions = { + paymentMethods: { + applePay: 'always', + googlePay: 'always', + link: 'auto', + }, + buttonType: { + googlePay: wcpayExpressCheckoutParams.button.type, + applePay: wcpayExpressCheckoutParams.button.type, + }, + }; + + const onCancel = () => { + onClose(); + }; + + const completePayment = ( redirectUrl ) => { + window.location = redirectUrl; + }; + + const abortPayment = ( onConfirmEvent, message ) => { + onConfirmEvent.paymentFailed( 'fail' ); + setExpressPaymentError( message ); + }; + + const onButtonClick = useCallback( + ( event ) => { + const options = { + lineItems: normalizeLineItems( billing?.cartTotalItems ), + emailRequired: true, + shippingAddressRequired: shippingData?.needsShipping, + phoneNumberRequired: + wcpayExpressCheckoutParams?.checkout?.needs_payer_phone, + shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map( + ( r ) => { + return { + id: r.rate_id, + amount: parseInt( r.price, 10 ), + displayName: r.name, + }; + } + ), + }; + event.resolve( options ); + onClick(); + }, + [ + onClick, + billing.cartTotalItems, + shippingData.needsShipping, + shippingData.shippingRates, + ] + ); + + const onConfirm = async ( event ) => { + onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + }; + + return { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + }; +}; diff --git a/client/express-checkout/blocks/index.js b/client/express-checkout/blocks/index.js index 17b2c1221dd..c7aac8d9b5c 100644 --- a/client/express-checkout/blocks/index.js +++ b/client/express-checkout/blocks/index.js @@ -4,13 +4,18 @@ * Internal dependencies */ import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from '../../checkout/constants'; -import { ExpressCheckout } from './express-checkout'; +import ExpressCheckoutContainer from './components/express-checkout-container'; import { getConfig } from '../../utils/checkout'; import ApplePayPreview from './apple-pay-preview'; const expressCheckoutElementPaymentMethod = ( api ) => ( { name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, - content: , + content: ( + + ), edit: , paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, supports: { diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js new file mode 100644 index 00000000000..e46931838a2 --- /dev/null +++ b/client/express-checkout/event-handlers.js @@ -0,0 +1,61 @@ +/** + * Internal dependencies + */ +import { normalizeOrderData, normalizeShippingAddress } from './utils'; +import { getErrorMessageFromNotice } from 'utils/express-checkout'; + +export const shippingAddressChangeHandler = async ( api, event ) => { + const response = await api.expressCheckoutECECalculateShippingOptions( + normalizeShippingAddress( event.shippingAddress ) + ); + event.resolve( { + shippingRates: response.shipping_options, + } ); +}; + +export const onConfirmHandler = async ( + api, + stripe, + elements, + completePayment, + abortPayment, + event +) => { + const { paymentMethod, error } = await stripe.createPaymentMethod( { + elements, + } ); + + if ( error ) { + abortPayment( event, error.message ); + return; + } + + // Kick off checkout processing step. + const createOrderResponse = await api.expressCheckoutECECreateOrder( + normalizeOrderData( event, paymentMethod.id ) + ); + + if ( createOrderResponse.result !== 'success' ) { + return abortPayment( + event, + getErrorMessageFromNotice( createOrderResponse.messages ) + ); + } + + try { + const confirmationRequest = api.confirmIntent( + createOrderResponse.redirect + ); + + // `true` means there is no intent to confirm. + if ( confirmationRequest === true ) { + completePayment( createOrderResponse.redirect ); + } else { + const redirectUrl = await confirmationRequest; + + completePayment( redirectUrl ); + } + } catch ( e ) { + abortPayment( event, error.message ); + } +}; diff --git a/client/express-checkout/utils/index.js b/client/express-checkout/utils/index.js new file mode 100644 index 00000000000..d29d7cccc32 --- /dev/null +++ b/client/express-checkout/utils/index.js @@ -0,0 +1 @@ +export * from './normalize'; diff --git a/client/express-checkout/utils/normalize.js b/client/express-checkout/utils/normalize.js new file mode 100644 index 00000000000..fd44ca77b5c --- /dev/null +++ b/client/express-checkout/utils/normalize.js @@ -0,0 +1,101 @@ +/** + * 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 + .filter( ( displayItem ) => { + return !! displayItem.value; + } ) + .map( ( displayItem ) => { + return { + amount: displayItem.value, + name: displayItem.label, + }; + } ); +}; + +/** + * Normalize order data from Stripe's object to the expected format for WC. + * + * @param {Object} event Stripe's event object. + * @param {Object} 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 phone = event?.billingDetails?.phone ?? ''; + const billing = event?.billingDetails?.address ?? {}; + const shipping = event?.shippingAddress ?? {}; + const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; + + 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 ?? event?.payerPhone?.replace( '/[() -]/g', '' ) ?? '', + 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_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 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?.region ?? '', + country: shippingAddress?.country ?? '', + postcode: shippingAddress?.postalCode?.replace( ' ', '' ) ?? '', + }; +}; diff --git a/client/utils/express-checkout/index.js b/client/utils/express-checkout/index.js index fb8fc89bc39..e0e93470d33 100644 --- a/client/utils/express-checkout/index.js +++ b/client/utils/express-checkout/index.js @@ -1,12 +1,12 @@ /* global wcpayPaymentRequestParams, wcpayExpressCheckoutParams */ /** - * Retrieves payment request data from global variable. + * Retrieves express checkout config from global variable. * * @param {string} key The object property key. * @return {mixed} Value of the object prop or null. */ -export const getPaymentRequestData = ( key ) => { +export const getExpressCheckoutConfig = ( key ) => { if ( typeof wcpayExpressCheckoutParams === 'object' && wcpayExpressCheckoutParams.hasOwnProperty( key ) @@ -23,16 +23,33 @@ export const getPaymentRequestData = ( key ) => { }; /** - * Get WC AJAX endpoint URL. + * Get WC AJAX endpoint URL for express checkout endpoints. * * @param {string} endpoint Endpoint. * @return {string} URL with interpolated endpoint. */ -export const getPaymentRequestAjaxURL = ( endpoint ) => - getPaymentRequestData( 'wc_ajax_url' ) +export const getExpressCheckoutAjaxURL = ( endpoint ) => + getExpressCheckoutConfig( 'wc_ajax_url' ) .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. * @@ -59,3 +76,15 @@ export const shouldUseGooglePayBrand = () => { 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/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 0ca27dd3788..fb5fa1633c3 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1063,6 +1063,10 @@ function_exists( 'wcs_order_contains_subscription' ) $factors[] = Factor::PAYMENT_REQUEST(); } + if ( defined( 'WCPAY_EXPRESS_CHECKOUT_CHECKOUT' ) && WCPAY_EXPRESS_CHECKOUT_CHECKOUT ) { + $factors[] = Factor::EXPRESS_CHECKOUT_ELEMENT(); + } + $router = wcpay_get_container()->get( Router::class ); return $router->should_use_new_payment_process( $factors ); } @@ -1794,7 +1798,7 @@ public function process_payment_for_order( $cart, $payment_information, $schedul $payment_method_type = $this->get_payment_method_type_for_setup_intent( $intent, $token ); } - if ( empty( $_POST['payment_request_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + if ( empty( $_POST['payment_request_type'] ) || empty( $_POST['express_payment_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification $this->set_payment_method_title_for_order( $order, $payment_method_type, $payment_method_details ); } diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index c459d0cefec..d4716873ccf 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -410,6 +410,7 @@ public static function init() { include_once __DIR__ . '/class-wc-payment-token-wcpay-sepa.php'; include_once __DIR__ . '/class-wc-payments-status.php'; include_once __DIR__ . '/class-wc-payments-token-service.php'; + include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-ajax-handler.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-display-handler.php'; include_once __DIR__ . '/express-checkout/class-wc-payments-express-checkout-button-handler.php'; include_once __DIR__ . '/class-wc-payments-payment-request-button-handler.php'; @@ -1628,10 +1629,12 @@ public static function maybe_enqueue_woopay_common_config_script( $should_enqueu */ public static function maybe_display_express_checkout_buttons() { if ( WC_Payments_Features::are_payments_enabled() ) { - $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); - $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() ); - $express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); - $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, self::get_express_checkout_helper() ); + $payment_request_button_handler = new WC_Payments_Payment_Request_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper() ); + $woopay_button_handler = new WC_Payments_WooPay_Button_Handler( self::$account, self::get_gateway(), self::$woopay_util, self::get_express_checkout_helper() ); + + $express_checkout_ajax_handler = new WC_Payments_Express_Checkout_Ajax_Handler( self::get_express_checkout_helper() ); + $express_checkout_element_button_handler = new WC_Payments_Express_Checkout_Button_Handler( self::$account, self::get_gateway(), self::get_express_checkout_helper(), $express_checkout_ajax_handler ); + $express_checkout_button_display_handler = new WC_Payments_Express_Checkout_Button_Display_Handler( self::get_gateway(), $payment_request_button_handler, $woopay_button_handler, $express_checkout_element_button_handler, $express_checkout_ajax_handler, self::get_express_checkout_helper() ); $express_checkout_button_display_handler->init(); } } diff --git a/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php new file mode 100644 index 00000000000..773f3c5d61b --- /dev/null +++ b/includes/express-checkout/class-wc-payments-express-checkout-ajax-handler.php @@ -0,0 +1,195 @@ +express_checkout_button_helper = $express_checkout_button_helper; + } + + /** + * Initialize hooks. + * + * @return void + */ + public function init() { + add_action( 'wc_ajax_wcpay_create_order', [ $this, 'ajax_create_order' ] ); + add_action( 'wc_ajax_wcpay_get_shipping_options', [ $this, 'ajax_get_shipping_options' ] ); + } + + /** + * 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_ECE_CHECKOUT' ) ) { + define( 'WCPAY_ECE_CHECKOUT', true ); + } + + // In case the state is required, but is missing, add a more descriptive error notice. + $this->express_checkout_button_helper->validate_state(); + + $this->express_checkout_button_helper->normalize_state(); + + WC()->checkout()->process_checkout(); + + die( 0 ); + } + + /** + * 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->express_checkout_button_helper->get_shipping_options( $shipping_address, $should_show_itemized_view ); + wp_send_json( $data ); + } + + /** + * Adds the current product to the cart. Used on product detail page. + */ + public function ajax_add_to_cart() { + check_ajax_referer( 'wcpay-add-to-cart', 'security' ); + + if ( ! defined( 'WOOCOMMERCE_CART' ) ) { + define( 'WOOCOMMERCE_CART', true ); + } + + WC()->shipping->reset_shipping(); + + $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; + $product = wc_get_product( $product_id ); + + if ( ! $product ) { + wp_send_json( + [ + 'error' => [ + 'code' => 'invalid_product_id', + 'message' => __( 'Invalid product id', 'woocommerce-payments' ), + ], + ], + 404 + ); + return; + } + + $quantity = $this->express_checkout_button_helper->get_quantity(); + + $product_type = $product->get_type(); + + $is_add_to_cart_valid = apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $quantity ); + + if ( ! $is_add_to_cart_valid ) { + // Some extensions error messages needs to be + // submitted to show error messages. + wp_send_json( + [ + 'error' => true, + 'submit' => true, + ], + 400 + ); + return; + } + + // First empty the cart to prevent wrong calculation. + WC()->cart->empty_cart(); + + if ( ( 'variable' === $product_type || 'variable-subscription' === $product_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 ); + + WC()->cart->add_to_cart( $product->get_id(), $quantity, $variation_id, $attributes ); + } + + if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking', 'bundle', 'mix-and-match' ], true ) ) { + WC()->cart->add_to_cart( $product->get_id(), $quantity ); + } + + WC()->cart->calculate_totals(); + + if ( 'booking' === $product_type ) { + $booking_id = $this->express_checkout_button_helper->get_booking_id_from_cart(); + } + + $data = []; + $data += $this->express_checkout_button_helper->build_display_items(); + $data['result'] = 'success'; + + if ( ! empty( $booking_id ) ) { + $data['bookingId'] = $booking_id; + } + + wp_send_json( $data ); + } + + /** + * Empties the cart via AJAX. Used on the product page. + */ + public function ajax_empty_cart() { + check_ajax_referer( 'wcpay-empty-cart', 'security' ); + + $booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : null; + + WC()->cart->empty_cart(); + + 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 + } + + wp_send_json( [ 'result' => 'success' ] ); + } +} 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 3fee5b1d9bd..ab132195f81 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 @@ -42,6 +42,13 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { */ private $express_checkout_button_handler; + /** + * Express Checkout Helper instance. + * + * @var WC_Payments_Express_Checkout_Ajax_Handler + */ + private $express_checkout_ajax_handler; + /** * Express Checkout Helper instance. * @@ -56,19 +63,22 @@ class WC_Payments_Express_Checkout_Button_Display_Handler { * @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_Button_Helper $express_checkout_helper Express checkout helper. + * @param WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler Express checkout ajax handlers. + * @param WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper Express checkout button helper. */ 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; $this->express_checkout_helper = $express_checkout_helper; } @@ -86,8 +96,8 @@ public function init() { $is_payment_request_enabled = 'yes' === $this->gateway->get_option( 'payment_request' ); if ( $is_woopay_enabled || $is_payment_request_enabled ) { - add_action( 'wc_ajax_wcpay_add_to_cart', [ $this->express_checkout_helper, 'ajax_add_to_cart' ] ); - add_action( 'wc_ajax_wcpay_empty_cart', [ $this->express_checkout_helper, 'ajax_empty_cart' ] ); + 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 ); @@ -120,12 +130,14 @@ public function display_express_checkout_separator_if_necessary( $separator_star * @return void */ public function display_express_checkout_buttons() { - $should_show_woopay = $this->platform_checkout_button_handler->should_show_woopay_button(); - $should_show_payment_request = $this->payment_request_button_handler->should_show_payment_request_button(); + $should_show_woopay = $this->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_payment_request && ! $should_show_woopay; - if ( $should_show_woopay || $should_show_payment_request ) { + $separator_starts_hidden = ( $should_show_payment_request || $should_show_express_checkout_button ) && ! $should_show_woopay; + if ( $should_show_woopay || $should_show_payment_request || $should_show_express_checkout_button ) { ?>
account = $account; - $this->gateway = $gateway; - $this->express_checkout_helper = $express_checkout_helper; + public function __construct( WC_Payments_Account $account, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Express_Checkout_Button_Helper $express_checkout_helper, WC_Payments_Express_Checkout_Ajax_Handler $express_checkout_ajax_handler ) { + $this->account = $account; + $this->gateway = $gateway; + $this->express_checkout_helper = $express_checkout_helper; + $this->express_checkout_ajax_handler = $express_checkout_ajax_handler; } /** @@ -81,6 +88,8 @@ public function init() { } add_action( 'wp_enqueue_scripts', [ $this, 'scripts' ] ); + + $this->express_checkout_ajax_handler->init(); } /** @@ -100,159 +109,12 @@ public function get_button_settings() { return array_merge( $common_settings, $payment_request_button_settings ); } - /** - * Checks whether Payment Request Button should be available on this page. - * - * @return bool - */ - public function should_show_express_checkout_button() { - // If account is not connected, then bail. - if ( ! $this->account->is_stripe_connected( false ) ) { - return false; - } - - // If no SSL, bail. - if ( ! WC_Payments::mode()->is_test() && ! is_ssl() ) { - Logger::log( 'Stripe Payment Request live mode requires SSL.' ); - - return false; - } - - // Page not supported. - if ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_checkout() ) { - return false; - } - - // Product page, but not available in settings. - if ( $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_available_at( 'product', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Checkout page, but not available in settings. - if ( $this->express_checkout_helper->is_checkout() && ! $this->express_checkout_helper->is_available_at( 'checkout', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Cart page, but not available in settings. - if ( $this->express_checkout_helper->is_cart() && ! $this->express_checkout_helper->is_available_at( 'cart', self::BUTTON_LOCATIONS ) ) { - return false; - } - - // Product page, but has unsupported product type. - if ( $this->express_checkout_helper->is_product() && ! $this->is_product_supported() ) { - Logger::log( 'Product page has unsupported product type ( Payment Request button disabled )' ); - return false; - } - - // Cart has unsupported product type. - if ( ( $this->express_checkout_helper->is_checkout() || $this->express_checkout_helper->is_cart() ) && ! $this->has_allowed_items_in_cart() ) { - Logger::log( 'Items in the cart have unsupported product type ( Payment Request button disabled )' ); - return false; - } - - // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. - if ( $this->express_checkout_helper->is_pay_for_order_page() ) { - return true; - } - - // Cart total is 0 or is on product page and product price is 0. - // Exclude pay-for-order pages from this check. - if ( - ( ! $this->express_checkout_helper->is_product() && ! $this->express_checkout_helper->is_pay_for_order_page() && 0.0 === (float) WC()->cart->get_total( 'edit' ) ) || - ( $this->express_checkout_helper->is_product() && 0.0 === (float) $this->express_checkout_helper->get_product()->get_price() ) - - ) { - Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); - return false; - } - - return true; - } - - /** - * Checks to make sure product type is supported. - * - * @return array - */ - public function supported_product_types() { - return apply_filters( - 'wcpay_payment_request_supported_types', - [ - 'simple', - 'variable', - 'variation', - 'subscription', - 'variable-subscription', - 'subscription_variation', - 'booking', - 'bundle', - 'composite', - 'mix-and-match', - ] - ); - } - - /** - * Checks the cart to see if all items are allowed to be used. - * - * @return boolean - * - * @psalm-suppress UndefinedClass - */ - public function has_allowed_items_in_cart() { - /** - * Pre Orders compatbility where we don't support charge upon release. - * - * @psalm-suppress UndefinedClass - */ - if ( class_exists( 'WC_Pre_Orders_Cart' ) && WC_Pre_Orders_Cart::cart_contains_pre_order() && class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() ) ) { - return false; - } - - foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { - $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); - - if ( ! in_array( $_product->get_type(), $this->supported_product_types(), true ) ) { - return false; - } - - /** - * Filter whether product supports Payment Request Button on cart page. - * - * @since 6.9.0 - * - * @param boolean $is_supported Whether product supports Payment Request Button on cart page. - * @param object $_product Product object. - */ - if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { - return false; - } - - /** - * Trial subscriptions with shipping are not supported. - * - * @psalm-suppress UndefinedClass - */ - if ( class_exists( 'WC_Subscriptions_Product' ) && WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $_product ) > 0 ) { - return false; - } - } - - // We don't support multiple packages with Payment Request Buttons because we can't offer a good UX. - $packages = WC()->cart->get_shipping_packages(); - if ( 1 < ( is_countable( $packages ) ? count( $packages ) : 0 ) ) { - return false; - } - - return true; - } - /** * Load public scripts and styles. */ public function scripts() { // Don't load scripts if page is not supported. - if ( ! $this->should_show_express_checkout_button() ) { + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { return; } @@ -288,7 +150,7 @@ public function scripts() { 'button_context' => $this->express_checkout_helper->get_button_context(), 'is_pay_for_order' => $this->express_checkout_helper->is_pay_for_order_page(), 'has_block' => has_block( 'woocommerce/cart' ) || has_block( 'woocommerce/checkout' ), - 'product' => $this->get_product_data(), + '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(), ]; @@ -320,235 +182,11 @@ public function scripts() { * Display the payment request button. */ public function display_express_checkout_button_html() { - if ( ! $this->should_show_express_checkout_button() ) { + if ( ! $this->express_checkout_helper->should_show_express_checkout_button() ) { return; } ?>
express_checkout_helper->get_product(); - $is_supported = true; - - /** - * Ignore undefined classes from 3rd party plugins. - * - * @psalm-suppress UndefinedClass - */ - if ( is_null( $product ) - || ! is_object( $product ) - || ! in_array( $product->get_type(), $this->supported_product_types(), true ) - || ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) // Trial subscriptions with shipping are not supported. - || ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) // Pre Orders charge upon release not supported. - || ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) // Composite products are not supported on the product page. - || ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) // Mix and match products are not supported on the product page. - ) { - $is_supported = false; - } elseif ( class_exists( 'WC_Product_Addons_Helper' ) ) { - // File upload addon not supported. - $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); - foreach ( $product_addons as $addon ) { - if ( 'file_upload' === $addon['type'] ) { - $is_supported = false; - break; - } - } - } - - return apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); - } - - /** - * Gets the product data for the currently viewed page. - * - * @return mixed Returns false if not on a product page, the product information otherwise. - */ - public function get_product_data() { - if ( ! $this->express_checkout_helper->is_product() ) { - return false; - } - - /** @var WC_Product_Variable $product */ // phpcs:ignore - $product = $this->express_checkout_helper->get_product(); - $currency = get_woocommerce_currency(); - - if ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) { - $variation_attributes = $product->get_variation_attributes(); - $attributes = []; - - foreach ( $variation_attributes as $attribute_name => $attribute_values ) { - $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); - - // Passed value via GET takes precedence. Otherwise get the default value for given attribute. - $attributes[ $attribute_key ] = isset( $_GET[ $attribute_key ] ) // phpcs:ignore WordPress.Security.NonceVerification - ? wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ) // phpcs:ignore WordPress.Security.NonceVerification - : $product->get_variation_default_attribute( $attribute_name ); - } - - $data_store = WC_Data_Store::load( 'product' ); - $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); - - if ( ! empty( $variation_id ) ) { - $product = wc_get_product( $variation_id ); - } - } - - try { - $price = $this->get_product_price( $product ); - } catch ( Invalid_Price_Exception $e ) { - Logger::log( $e->getMessage() ); - return false; - } - - $data = []; - $items = []; - - $items[] = [ - 'label' => $product->get_name(), - 'amount' => WC_Payments_Utils::prepare_amount( $price, $currency ), - ]; - - $total_tax = 0; - foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { - $total_tax += $tax; - - $items[] = [ - 'label' => __( 'Tax', 'woocommerce-payments' ), - 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), - 'pending' => 0 === $tax, - ]; - } - - if ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ) { - $items[] = [ - 'label' => __( 'Shipping', 'woocommerce-payments' ), - 'amount' => 0, - 'pending' => true, - ]; - - $data['shippingOptions'] = [ - 'id' => 'pending', - 'label' => __( 'Pending', 'woocommerce-payments' ), - 'detail' => '', - 'amount' => 0, - ]; - } - - $data['displayItems'] = $items; - $data['total'] = [ - 'label' => apply_filters( 'wcpay_payment_request_total_label', $this->express_checkout_helper->get_total_label() ), - 'amount' => WC_Payments_Utils::prepare_amount( $price + $total_tax, $currency ), - 'pending' => true, - ]; - - $data['needs_shipping'] = ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ); - $data['currency'] = strtolower( $currency ); - $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); - - return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); - } - - /** - * Gets the product total price. - * - * @param object $product WC_Product_* object. - * @param bool $is_deposit Whether customer is paying a deposit. - * @param int $deposit_plan_id The ID of the deposit plan. - * @return mixed Total price. - * - * @throws Invalid_Price_Exception Whenever a product has no price. - * - * @psalm-suppress UndefinedClass - */ - public function get_product_price( $product, ?bool $is_deposit = null, int $deposit_plan_id = 0 ) { - // If prices should include tax, using tax inclusive price. - if ( $this->express_checkout_helper->cart_prices_include_tax() ) { - $base_price = wc_get_price_including_tax( $product ); - } else { - $base_price = wc_get_price_excluding_tax( $product ); - } - - // If WooCommerce Deposits is active, we need to get the correct price for the product. - if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { - if ( is_null( $is_deposit ) ) { - /** - * If is_deposit is null, we use the default deposit type for the product. - * - * @psalm-suppress UndefinedClass - */ - $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); - } - if ( $is_deposit ) { - /** - * Ignore undefined classes from 3rd party plugins. - * - * @psalm-suppress UndefinedClass - */ - $deposit_type = WC_Deposits_Product_Manager::get_deposit_type( $product->get_id() ); - $available_plan_ids = WC_Deposits_Plans_Manager::get_plan_ids_for_product( $product->get_id() ); - // Default to first (default) plan if no plan is specified. - if ( 'plan' === $deposit_type && 0 === $deposit_plan_id && ! empty( $available_plan_ids ) ) { - $deposit_plan_id = $available_plan_ids[0]; - } - - // Ensure the selected plan is available for the product. - if ( 0 === $deposit_plan_id || in_array( $deposit_plan_id, $available_plan_ids, true ) ) { - $base_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $base_price ); - } - } - } - - // Add subscription sign-up fees to product price. - $sign_up_fee = 0; - $subscription_types = [ - 'subscription', - 'subscription_variation', - ]; - if ( in_array( $product->get_type(), $subscription_types, true ) && class_exists( 'WC_Subscriptions_Product' ) ) { - // When there is no sign-up fee, `get_sign_up_fee` falls back to an int 0. - $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); - } - - if ( ! is_numeric( $base_price ) || ! is_numeric( $sign_up_fee ) ) { - $error_message = sprintf( - // Translators: %d is the numeric ID of the product without a price. - __( 'Express checkout does not support products without prices! Please add a price to product #%d', 'woocommerce-payments' ), - (int) $product->get_id() - ); - throw new Invalid_Price_Exception( - esc_html( $error_message ) - ); - } - - return $base_price + $sign_up_fee; - } - - /** - * Calculates taxes as displayed on cart, based on a product and a particular price. - * - * @param WC_Product $product The product, for retrieval of tax classes. - * @param float $price The price, which to calculate taxes for. - * @return array An array of final taxes. - */ - private function get_taxes_like_cart( $product, $price ) { - if ( ! wc_tax_enabled() || $this->express_checkout_helper->cart_prices_include_tax() ) { - // Only proceed when taxes are enabled, but not included. - return []; - } - - // Follows the way `WC_Cart_Totals::get_item_tax_rates()` works. - $tax_class = $product->get_tax_class(); - $rates = WC_Tax::get_rates( $tax_class ); - // No cart item, `woocommerce_cart_totals_get_item_tax_rates` can't be applied here. - - // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. - return WC_Tax::calc_tax( $price, $rates, false ); - } } \ No newline at end of file diff --git a/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php b/includes/express-checkout/class-wc-payments-express-checkout-button-helper.php index 75495a3b990..266e69e6f6d 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 @@ -7,6 +7,10 @@ defined( 'ABSPATH' ) || exit; +use WCPay\Constants\Country_Code; +use WCPay\Exceptions\Invalid_Price_Exception; +use WCPay\Logger; + /** * Express Checkout Button Helper class. */ @@ -36,86 +40,6 @@ public function __construct( WC_Payment_Gateway_WCPay $gateway, WC_Payments_Acco $this->account = $account; } - /** - * Adds the current product to the cart. Used on product detail page. - */ - public function ajax_add_to_cart() { - check_ajax_referer( 'wcpay-add-to-cart', 'security' ); - - if ( ! defined( 'WOOCOMMERCE_CART' ) ) { - define( 'WOOCOMMERCE_CART', true ); - } - - WC()->shipping->reset_shipping(); - - $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : false; - $product = wc_get_product( $product_id ); - - if ( ! $product ) { - wp_send_json( - [ - 'error' => [ - 'code' => 'invalid_product_id', - 'message' => __( 'Invalid product id', 'woocommerce-payments' ), - ], - ], - 404 - ); - return; - } - - $quantity = $this->get_quantity(); - - $product_type = $product->get_type(); - - $is_add_to_cart_valid = apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $quantity ); - - if ( ! $is_add_to_cart_valid ) { - // Some extensions error messages needs to be - // submitted to show error messages. - wp_send_json( - [ - 'error' => true, - 'submit' => true, - ], - 400 - ); - return; - } - - // First empty the cart to prevent wrong calculation. - WC()->cart->empty_cart(); - - if ( ( 'variable' === $product_type || 'variable-subscription' === $product_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 ); - - WC()->cart->add_to_cart( $product->get_id(), $quantity, $variation_id, $attributes ); - } - - if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking', 'bundle', 'mix-and-match' ], true ) ) { - WC()->cart->add_to_cart( $product->get_id(), $quantity ); - } - - WC()->cart->calculate_totals(); - - if ( 'booking' === $product_type ) { - $booking_id = $this->get_booking_id_from_cart(); - } - - $data = []; - $data += $this->build_display_items(); - $data['result'] = 'success'; - - if ( ! empty( $booking_id ) ) { - $data['bookingId'] = $booking_id; - } - - wp_send_json( $data ); - } - /** * Gets the booking id from the cart. * It's expected that the cart only contains one item which was added via ajax_add_to_cart. @@ -134,26 +58,6 @@ public function get_booking_id_from_cart() { return false; } - /** - * Empties the cart via AJAX. Used on the product page. - */ - public function ajax_empty_cart() { - check_ajax_referer( 'wcpay-empty-cart', 'security' ); - - $booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : null; - - WC()->cart->empty_cart(); - - 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 - } - - wp_send_json( [ 'result' => 'success' ] ); - } - /** * Builds the line items to pass to Payment Request * @@ -277,7 +181,7 @@ public function get_total_label() { * * @return int */ - private function get_quantity() { + public function get_quantity() { // Payment Request Button sends the quantity as qty. WooPay sends it as quantity. if ( isset( $_POST['quantity'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return absint( $_POST['quantity'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing @@ -433,4 +337,757 @@ public function is_product_subscription( WC_Product $product ): bool { || 'subscription_variation' === $product->get_type() || 'variable-subscription' === $product->get_type(); } + + /** + * Checks whether Payment Request Button should be available on this page. + * + * @return bool + */ + public function should_show_express_checkout_button() { + // If account is not connected, then bail. + if ( ! $this->account->is_stripe_connected( false ) ) { + return false; + } + + // If no SSL, bail. + if ( ! WC_Payments::mode()->is_test() && ! is_ssl() ) { + Logger::log( 'Stripe Payment Request live mode requires SSL.' ); + + return false; + } + + // Page not supported. + if ( ! $this->is_product() && ! $this->is_cart() && ! $this->is_checkout() ) { + return false; + } + + // Product page, but not available in settings. + if ( $this->is_product() && ! $this->is_available_at( 'product', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Checkout page, but not available in settings. + if ( $this->is_checkout() && ! $this->is_available_at( 'checkout', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Cart page, but not available in settings. + if ( $this->is_cart() && ! $this->is_available_at( 'cart', WC_Payments_Express_Checkout_Button_Handler::BUTTON_LOCATIONS ) ) { + return false; + } + + // Product page, but has unsupported product type. + if ( $this->is_product() && ! $this->is_product_supported() ) { + Logger::log( 'Product page has unsupported product type ( Payment Request button disabled )' ); + return false; + } + + // Cart has unsupported product type. + if ( ( $this->is_checkout() || $this->is_cart() ) && ! $this->has_allowed_items_in_cart() ) { + Logger::log( 'Items in the cart have unsupported product type ( Payment Request button disabled )' ); + return false; + } + + // Order total doesn't matter for Pay for Order page. Thus, this page should always display payment buttons. + if ( $this->is_pay_for_order_page() ) { + return true; + } + + // Cart total is 0 or is on product page and product price is 0. + // Exclude pay-for-order pages from this check. + if ( + ( ! $this->is_product() && ! $this->is_pay_for_order_page() && 0.0 === (float) WC()->cart->get_total( 'edit' ) ) || + ( $this->is_product() && 0.0 === (float) $this->get_product()->get_price() ) + + ) { + Logger::log( 'Order price is 0 ( Payment Request button disabled )' ); + return false; + } + + return true; + } + + /** + * Checks to make sure product type is supported. + * + * @return array + */ + public function supported_product_types() { + return apply_filters( + 'wcpay_payment_request_supported_types', + [ + 'simple', + 'variable', + 'variation', + 'subscription', + 'variable-subscription', + 'subscription_variation', + 'booking', + 'bundle', + 'composite', + 'mix-and-match', + ] + ); + } + + /** + * Checks the cart to see if all items are allowed to be used. + * + * @return boolean + * + * @psalm-suppress UndefinedClass + */ + public function has_allowed_items_in_cart() { + /** + * Pre Orders compatbility where we don't support charge upon release. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Pre_Orders_Cart' ) && WC_Pre_Orders_Cart::cart_contains_pre_order() && class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( WC_Pre_Orders_Cart::get_pre_order_product() ) ) { + return false; + } + + foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) { + $_product = apply_filters( 'woocommerce_cart_item_product', $cart_item['data'], $cart_item, $cart_item_key ); + + if ( ! in_array( $_product->get_type(), $this->supported_product_types(), true ) ) { + return false; + } + + /** + * Filter whether product supports Payment Request Button on cart page. + * + * @since 6.9.0 + * + * @param boolean $is_supported Whether product supports Payment Request Button on cart page. + * @param object $_product Product object. + */ + if ( ! apply_filters( 'wcpay_payment_request_is_cart_supported', true, $_product ) ) { + return false; + } + + /** + * Trial subscriptions with shipping are not supported. + * + * @psalm-suppress UndefinedClass + */ + if ( class_exists( 'WC_Subscriptions_Product' ) && WC_Subscriptions_Product::is_subscription( $_product ) && $_product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $_product ) > 0 ) { + return false; + } + } + + // We don't support multiple packages with Payment Request Buttons because we can't offer a good UX. + $packages = WC()->cart->get_shipping_packages(); + if ( 1 < ( is_countable( $packages ) ? count( $packages ) : 0 ) ) { + return false; + } + + return true; + } + + /** + * 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_key => $package ) { + if ( empty( $package['rates'] ) ) { + throw new Exception( __( 'Unable to find shipping method for address.', 'woocommerce-payments' ) ); + } + + foreach ( $package['rates'] as $key => $rate ) { + $data['shipping_options'][] = [ + 'id' => $rate->id, + 'displayName' => $rate->label, + '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->build_display_items( $itemized_display_items ); + $data['result'] = 'success'; + } catch ( Exception $e ) { + $data += $this->build_display_items( $itemized_display_items ); + $data['result'] = 'invalid_shipping_address'; + } + + return $data; + } + + /** + * 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 ) { + // phpcs:ignore + /** + * @psalm-suppress UndefinedClass + */ + $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 ); + } + + /** + * Gets the product data for the currently viewed page. + * + * @return mixed Returns false if not on a product page, the product information otherwise. + */ + public function get_product_data() { + if ( ! $this->is_product() ) { + return false; + } + + /** @var WC_Product_Variable $product */ // phpcs:ignore + $product = $this->get_product(); + $currency = get_woocommerce_currency(); + + if ( 'variable' === $product->get_type() || 'variable-subscription' === $product->get_type() ) { + $variation_attributes = $product->get_variation_attributes(); + $attributes = []; + + foreach ( $variation_attributes as $attribute_name => $attribute_values ) { + $attribute_key = 'attribute_' . sanitize_title( $attribute_name ); + + // Passed value via GET takes precedence. Otherwise get the default value for given attribute. + $attributes[ $attribute_key ] = isset( $_GET[ $attribute_key ] ) // phpcs:ignore WordPress.Security.NonceVerification + ? wc_clean( wp_unslash( $_GET[ $attribute_key ] ) ) // phpcs:ignore WordPress.Security.NonceVerification + : $product->get_variation_default_attribute( $attribute_name ); + } + + $data_store = WC_Data_Store::load( 'product' ); + $variation_id = $data_store->find_matching_product_variation( $product, $attributes ); + + if ( ! empty( $variation_id ) ) { + $product = wc_get_product( $variation_id ); + } + } + + try { + $price = $this->get_product_price( $product ); + } catch ( Invalid_Price_Exception $e ) { + Logger::log( $e->getMessage() ); + return false; + } + + $data = []; + $items = []; + + $items[] = [ + 'label' => $product->get_name(), + 'amount' => WC_Payments_Utils::prepare_amount( $price, $currency ), + ]; + + $total_tax = 0; + foreach ( $this->get_taxes_like_cart( $product, $price ) as $tax ) { + $total_tax += $tax; + + $items[] = [ + 'label' => __( 'Tax', 'woocommerce-payments' ), + 'amount' => WC_Payments_Utils::prepare_amount( $tax, $currency ), + 'pending' => 0 === $tax, + ]; + } + + if ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ) { + $items[] = [ + 'label' => __( 'Shipping', 'woocommerce-payments' ), + 'amount' => 0, + 'pending' => true, + ]; + + $data['shippingOptions'] = [ + 'id' => 'pending', + 'label' => __( 'Pending', 'woocommerce-payments' ), + 'detail' => '', + 'amount' => 0, + ]; + } + + $data['displayItems'] = $items; + $data['total'] = [ + 'label' => apply_filters( 'wcpay_payment_request_total_label', $this->get_total_label() ), + 'amount' => WC_Payments_Utils::prepare_amount( $price + $total_tax, $currency ), + 'pending' => true, + ]; + + $data['needs_shipping'] = ( wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping() ); + $data['currency'] = strtolower( $currency ); + $data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 ); + + return apply_filters( 'wcpay_payment_request_product_data', $data, $product ); + } + + /** + * Whether product page has a supported product. + * + * @return boolean + */ + private function is_product_supported() { + $product = $this->get_product(); + $is_supported = true; + + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + if ( is_null( $product ) + || ! is_object( $product ) + || ! in_array( $product->get_type(), $this->supported_product_types(), true ) + || ( class_exists( 'WC_Subscriptions_Product' ) && $product->needs_shipping() && WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) // Trial subscriptions with shipping are not supported. + || ( class_exists( 'WC_Pre_Orders_Product' ) && WC_Pre_Orders_Product::product_is_charged_upon_release( $product ) ) // Pre Orders charge upon release not supported. + || ( class_exists( 'WC_Composite_Products' ) && $product->is_type( 'composite' ) ) // Composite products are not supported on the product page. + || ( class_exists( 'WC_Mix_and_Match' ) && $product->is_type( 'mix-and-match' ) ) // Mix and match products are not supported on the product page. + ) { + $is_supported = false; + } elseif ( class_exists( 'WC_Product_Addons_Helper' ) ) { + // File upload addon not supported. + $product_addons = WC_Product_Addons_Helper::get_product_addons( $product->get_id() ); + foreach ( $product_addons as $addon ) { + if ( 'file_upload' === $addon['type'] ) { + $is_supported = false; + break; + } + } + } + + return apply_filters( 'wcpay_payment_request_is_product_supported', $is_supported, $product ); + } + + /** + * Gets the product total price. + * + * @param object $product WC_Product_* object. + * @param bool $is_deposit Whether customer is paying a deposit. + * @param int $deposit_plan_id The ID of the deposit plan. + * @return mixed Total price. + * + * @throws Invalid_Price_Exception Whenever a product has no price. + * + * @psalm-suppress UndefinedClass + */ + public function get_product_price( $product, ?bool $is_deposit = null, int $deposit_plan_id = 0 ) { + // If prices should include tax, using tax inclusive price. + if ( $this->cart_prices_include_tax() ) { + $base_price = wc_get_price_including_tax( $product ); + } else { + $base_price = wc_get_price_excluding_tax( $product ); + } + + // If WooCommerce Deposits is active, we need to get the correct price for the product. + if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + if ( is_null( $is_deposit ) ) { + /** + * If is_deposit is null, we use the default deposit type for the product. + * + * @psalm-suppress UndefinedClass + */ + $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); + } + if ( $is_deposit ) { + /** + * Ignore undefined classes from 3rd party plugins. + * + * @psalm-suppress UndefinedClass + */ + $deposit_type = WC_Deposits_Product_Manager::get_deposit_type( $product->get_id() ); + $available_plan_ids = WC_Deposits_Plans_Manager::get_plan_ids_for_product( $product->get_id() ); + // Default to first (default) plan if no plan is specified. + if ( 'plan' === $deposit_type && 0 === $deposit_plan_id && ! empty( $available_plan_ids ) ) { + $deposit_plan_id = $available_plan_ids[0]; + } + + // Ensure the selected plan is available for the product. + if ( 0 === $deposit_plan_id || in_array( $deposit_plan_id, $available_plan_ids, true ) ) { + $base_price = WC_Deposits_Product_Manager::get_deposit_amount( $product, $deposit_plan_id, 'display', $base_price ); + } + } + } + + // Add subscription sign-up fees to product price. + $sign_up_fee = 0; + $subscription_types = [ + 'subscription', + 'subscription_variation', + ]; + if ( in_array( $product->get_type(), $subscription_types, true ) && class_exists( 'WC_Subscriptions_Product' ) ) { + // When there is no sign-up fee, `get_sign_up_fee` falls back to an int 0. + $sign_up_fee = WC_Subscriptions_Product::get_sign_up_fee( $product ); + } + + if ( ! is_numeric( $base_price ) || ! is_numeric( $sign_up_fee ) ) { + $error_message = sprintf( + // Translators: %d is the numeric ID of the product without a price. + __( 'Express checkout does not support products without prices! Please add a price to product #%d', 'woocommerce-payments' ), + (int) $product->get_id() + ); + throw new Invalid_Price_Exception( + esc_html( $error_message ) + ); + } + + return $base_price + $sign_up_fee; + } + + /** + * Calculates taxes as displayed on cart, based on a product and a particular price. + * + * @param WC_Product $product The product, for retrieval of tax classes. + * @param float $price The price, which to calculate taxes for. + * @return array An array of final taxes. + */ + private function get_taxes_like_cart( $product, $price ) { + if ( ! wc_tax_enabled() || $this->cart_prices_include_tax() ) { + // Only proceed when taxes are enabled, but not included. + return []; + } + + // Follows the way `WC_Cart_Totals::get_item_tax_rates()` works. + $tax_class = $product->get_tax_class(); + $rates = WC_Tax::get_rates( $tax_class ); + // No cart item, `woocommerce_cart_totals_get_item_tax_rates` can't be applied here. + + // Normally there should be a single tax, but `calc_tax` returns an array, let's use it. + return WC_Tax::calc_tax( $price, $rates, false ); + } + + /** + * 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 static 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' + ); + } + } + + /** + * 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 ); + } + + /** + * 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; + } + + /** + * Normalizes postal code in case of redacted data from Apple Pay. + * + * @param string $postcode Postal code. + * @param string $country Country. + */ + public function get_normalized_postal_code( $postcode, $country ) { + /** + * Currently, Apple Pay truncates the UK and Canadian postal codes to the first 4 and 3 characters respectively + * when passing it back from the shippingcontactselected object. This causes WC to invalidate + * the postal code and not calculate shipping zones correctly. + */ + if ( Country_Code::UNITED_KINGDOM === $country ) { + // Replaces a redacted string with something like N1C0000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 7, '0' ); + } + if ( Country_Code::CANADA === $country ) { + // Replaces a redacted string with something like H3B000. + return str_pad( preg_replace( '/\s+/', '', $postcode ), 6, '0' ); + } + + return $postcode; + } + + /** + * 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 ) ) ); + } + + /** + * 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 ); + } + + /** + * 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 ); + } } diff --git a/includes/woopay/services/class-checkout-service.php b/includes/woopay/services/class-checkout-service.php index f10ca7bad7b..b776d505f27 100644 --- a/includes/woopay/services/class-checkout-service.php +++ b/includes/woopay/services/class-checkout-service.php @@ -64,7 +64,7 @@ public function create_and_confirm_setup_intention_request( Request $base_reques */ public function is_platform_payment_method( Payment_Information $payment_information ) { // Return false for express checkout method. - if ( isset( $_POST['payment_request_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + if ( isset( $_POST['payment_request_type'] ) || isset( $_POST['express_payment_type'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification return false; } diff --git a/src/Internal/Payment/Factor.php b/src/Internal/Payment/Factor.php index 3a63e895738..957c131fd7a 100644 --- a/src/Internal/Payment/Factor.php +++ b/src/Internal/Payment/Factor.php @@ -100,6 +100,12 @@ class Factor extends Base_Constant { */ const PAYMENT_REQUEST = 'PAYMENT_REQUEST'; + /** + * ECE buttons (Google Pay and Apple Pay) + * Type: Entry point + */ + const EXPRESS_CHECKOUT_ELEMENT = 'EXPRESS_CHECKOUT_ELEMENT'; + /** * Returns all possible factors. * @@ -121,6 +127,7 @@ public static function get_all_factors() { static::IPP_CAPTURE(), static::STRIPE_LINK(), static::PAYMENT_REQUEST(), + static::EXPRESS_CHECKOUT_ELEMENT(), ]; } } diff --git a/tests/unit/src/Internal/Payment/FactorTest.php b/tests/unit/src/Internal/Payment/FactorTest.php index e1e1e14ba71..c570d6dcfe0 100644 --- a/tests/unit/src/Internal/Payment/FactorTest.php +++ b/tests/unit/src/Internal/Payment/FactorTest.php @@ -36,6 +36,7 @@ public function test_get_all_factors() { 'IPP_CAPTURE', 'STRIPE_LINK', 'PAYMENT_REQUEST', + 'EXPRESS_CHECKOUT_ELEMENT', ]; $result = Factor::get_all_factors(); diff --git a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php index 40329be06fd..2fc3948b716 100644 --- a/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php +++ b/tests/unit/test-class-wc-payments-express-checkout-button-display-handler.php @@ -79,6 +79,13 @@ class WC_Payments_Express_Checkout_Button_Display_Handler_Test extends WCPAY_Uni */ private $mock_express_checkout_helper; + /** + * Express Checkout Ajax Handler instance. + * + * @var WC_Payments_Express_Checkout_Ajax_Handler + */ + private $mock_express_checkout_ajax_handler; + /** * Sets up things all tests need. */ @@ -117,6 +124,14 @@ public function set_up() { ) ->getMock(); + $this->mock_express_checkout_ajax_handler = $this->getMockBuilder( WC_Payments_Express_Checkout_Ajax_Handler::class ) + ->setConstructorArgs( + [ + $this->mock_express_checkout_helper, + ] + ) + ->getMock(); + $this->mock_woopay_button_handler = $this->getMockBuilder( WC_Payments_WooPay_Button_Handler::class ) ->setConstructorArgs( [ @@ -156,6 +171,7 @@ public function set_up() { $this->mock_wcpay_account, $this->mock_wcpay_gateway, $this->mock_express_checkout_helper, + $this->mock_express_checkout_ajax_handler, ] ) ->setMethods( @@ -165,14 +181,15 @@ public function set_up() { ) ->getMock(); - $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_helper - ); - $this->express_checkout_button_display_handler->init(); + $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, + $this->mock_express_checkout_helper + ); + $this->express_checkout_button_display_handler->init(); add_filter( 'woocommerce_available_payment_gateways', From 200a92e1eff7298e0651b0580cdcf2ceceae9b78 Mon Sep 17 00:00:00 2001 From: Timur Karimov Date: Mon, 3 Jun 2024 22:31:26 +0200 Subject: [PATCH 09/52] Fix QIT errors reported by PHPStan (#8845) --- changelog/fix-phpstan-errors | 4 ++ includes/class-wc-payments-account.php | 12 +---- ...ayments-payment-request-button-handler.php | 2 +- includes/class-wc-payments.php | 52 +++++++++---------- .../blocks/class-blocks-data-extractor.php | 4 ++ ...ents-email-failed-authentication-retry.php | 11 +++- ...wc-payment-gateway-wcpay-subscriptions.php | 12 ++++- includes/constants/class-base-constant.php | 2 + ...ayments-express-checkout-button-helper.php | 2 +- .../WooCommerceNameYourPrice.php | 5 +- .../WooCommerceProductAddOns.php | 20 ++++--- .../class-woopay-adapted-extensions.php | 23 ++++---- .../woopay/class-woopay-store-api-token.php | 1 + .../Payment/State/AbstractPaymentState.php | 10 ++-- src/Internal/Payment/State/StateFactory.php | 10 ++-- tests/qit/README.md | 11 ++-- 16 files changed, 108 insertions(+), 73 deletions(-) create mode 100644 changelog/fix-phpstan-errors diff --git a/changelog/fix-phpstan-errors b/changelog/fix-phpstan-errors new file mode 100644 index 00000000000..173e71e8d88 --- /dev/null +++ b/changelog/fix-phpstan-errors @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Fix PHPStan warnings. diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index b3106527a2d..79b11b6646b 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -1675,7 +1675,7 @@ function () { * * @return void */ - public function update_cached_account_data( $property, $data ) { + public function update_account_data( $property, $data ) { $account_data = $this->database_cache->get( Database_Cache::ACCOUNT_KEY ); $account_data[ $property ] = is_array( $data ) ? array_merge( $account_data[ $property ] ?? [], $data ) : $data; @@ -1692,16 +1692,6 @@ public function refresh_account_data() { return $this->get_cached_account_data( true ); } - /** - * Updates the account data. - * - * @param string $property Property to update. - * @param mixed $data Data to update. - */ - public function update_account_data( $property, $data ) { - return $this->update_cached_account_data( $property, $data ); - } - /** * Checks if the cached account can be used in the current plugin state. * diff --git a/includes/class-wc-payments-payment-request-button-handler.php b/includes/class-wc-payments-payment-request-button-handler.php index fd7de809e9a..04a0fbdb83f 100644 --- a/includes/class-wc-payments-payment-request-button-handler.php +++ b/includes/class-wc-payments-payment-request-button-handler.php @@ -408,7 +408,7 @@ public function get_product_price( $product, ?bool $is_deposit = null, int $depo } // If WooCommerce Deposits is active, we need to get the correct price for the product. - if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + if ( class_exists( 'WC_Deposits_Product_Manager' ) && class_exists( 'WC_Deposits_Plans_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { // If is_deposit is null, we use the default deposit type for the product. if ( is_null( $is_deposit ) ) { $is_deposit = 'deposit' === WC_Deposits_Product_Manager::get_deposit_selected_type( $product->get_id() ); diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index d4716873ccf..04b2e517c1f 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -1437,37 +1437,37 @@ public static function add_woo_admin_notes() { } if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.5', '<' ) && get_woocommerce_currency() === 'NOK' ) { - /** - * Shows an alert notice for Norwegian merchants on WooCommerce 7.4 and below - */ - function wcpay_show_old_woocommerce_for_norway_notice() { - ?> -
-

- the plugins page.', 'woocommerce-payments' ), - 'WooCommerce', - 'WooPayments' - ), - [ - 'a1' => '', - ] - ) - ?> -

-
- + + get( \MailPoet\WooCommerce\Subscription::class ); // phpcs:ignore /** * @psalm-suppress UndefinedClass + * @phpstan-ignore-next-line */ $settings_instance = \MailPoet\Settings\SettingsController::getInstance(); $settings = [ diff --git a/includes/compat/subscriptions/class-wc-payments-email-failed-authentication-retry.php b/includes/compat/subscriptions/class-wc-payments-email-failed-authentication-retry.php index fb59585534f..7d4838bfceb 100644 --- a/includes/compat/subscriptions/class-wc-payments-email-failed-authentication-retry.php +++ b/includes/compat/subscriptions/class-wc-payments-email-failed-authentication-retry.php @@ -9,6 +9,8 @@ * @package WooCommerce\Payments */ +use WCPay\Logger; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -18,6 +20,13 @@ */ class WC_Payments_Email_Failed_Authentication_Retry extends WC_Email_Failed_Order { + /** + * The details of the last retry (if any) recorded for a given order + * + * @var WCS_Retry + */ + private $retry; + /** * Constructor */ @@ -71,7 +80,7 @@ public function trigger( $order_id, $order = null ) { $this->retry = WCS_Retry_Manager::store()->get_last_retry_for_order( wcs_get_objects_property( $order, 'id' ) ); $this->replace['retry-time'] = wcs_get_human_time_diff( $this->retry->get_time() ); } else { - WC_Stripe_Logger::log( 'WCS_Retry_Manager class or does not exist. Not able to send admin email about customer notification for authentication required for renewal payment.' ); + Logger::log( 'WCS_Retry_Manager class or does not exist. Not able to send admin email about customer notification for authentication required for renewal payment.' ); return; } diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index 09c1ac59c86..da7a1379c14 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -103,6 +103,14 @@ abstract protected function prepare_payment_information( $order ); */ private static $has_attached_integration_hooks = false; + /** + * Used to temporary keep the state of the order_pay value on the Pay for order page with the SCA authorization flow. + * For more details, see remove_order_pay_var and restore_order_pay_var hooks. + * + * @var string|int + */ + private $order_pay_var; + /** * Initialize subscription support and hooks. */ @@ -941,7 +949,9 @@ public function get_mandate_params_for_order( WC_Order $order ): array { if ( 1 < count( $subscriptions ) ) { $result['card']['mandate_options']['amount_type'] = 'maximum'; $result['card']['mandate_options']['interval'] = 'sporadic'; - unset( $result['card']['mandate_options']['interval_count'] ); + if ( isset( $result['card']['mandate_options']['interval_count'] ) ) { + unset( $result['card']['mandate_options']['interval_count'] ); + } } return $result; diff --git a/includes/constants/class-base-constant.php b/includes/constants/class-base-constant.php index be1a5770922..876150b6581 100644 --- a/includes/constants/class-base-constant.php +++ b/includes/constants/class-base-constant.php @@ -98,6 +98,8 @@ public static function search( string $value ) { */ public static function __callStatic( $name, $arguments ) { if ( ! isset( static::$object_cache[ $name ] ) ) { + // Instantiating constants by class name using the 'new static($name)' approach is integral to this method's functionality. + // @phpstan-ignore-next-line. static::$object_cache[ $name ] = new static( $name ); } return static::$object_cache[ $name ]; 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 266e69e6f6d..401dc47a389 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 @@ -750,7 +750,7 @@ public function get_product_price( $product, ?bool $is_deposit = null, int $depo } // If WooCommerce Deposits is active, we need to get the correct price for the product. - if ( class_exists( 'WC_Deposits_Product_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { + if ( class_exists( 'WC_Deposits_Product_Manager' ) && class_exists( 'WC_Deposits_Plans_Manager' ) && WC_Deposits_Product_Manager::deposits_enabled( $product->get_id() ) ) { if ( is_null( $is_deposit ) ) { /** * If is_deposit is null, we use the default deposit type for the product. diff --git a/includes/multi-currency/Compatibility/WooCommerceNameYourPrice.php b/includes/multi-currency/Compatibility/WooCommerceNameYourPrice.php index 0095bc70559..155f99e1a4d 100644 --- a/includes/multi-currency/Compatibility/WooCommerceNameYourPrice.php +++ b/includes/multi-currency/Compatibility/WooCommerceNameYourPrice.php @@ -63,7 +63,7 @@ public function add_initial_currency( $cart_item, $product_id, $variation_id ) { $nyp_id = $variation_id ? $variation_id : $product_id; - if ( \WC_Name_Your_Price_Helpers::is_nyp( $nyp_id ) && isset( $cart_item['nyp'] ) ) { + if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $nyp_id ) && isset( $cart_item['nyp'] ) ) { $currency = $this->multi_currency->get_selected_currency(); $cart_item['nyp_currency'] = $currency->get_code(); $cart_item['nyp_original'] = $cart_item['nyp']; @@ -102,6 +102,7 @@ public function convert_cart_currency( $cart_item, $values ) { $cart_item['nyp'] = $this->multi_currency->get_raw_conversion( $raw_price, $selected_currency->get_code(), $from_currency ); } + // @phpstan-ignore-next-line. $cart_item = WC_Name_Your_Price()->cart->set_cart_item( $cart_item ); } @@ -130,7 +131,7 @@ public function should_convert_product_price( bool $return, $product ): bool { } // Check to see if the product is a NYP product. - if ( \WC_Name_Your_Price_Helpers::is_nyp( $product ) ) { + if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product ) ) { return false; } diff --git a/includes/multi-currency/Compatibility/WooCommerceProductAddOns.php b/includes/multi-currency/Compatibility/WooCommerceProductAddOns.php index f3e04db64fc..adc5cb9dabb 100644 --- a/includes/multi-currency/Compatibility/WooCommerceProductAddOns.php +++ b/includes/multi-currency/Compatibility/WooCommerceProductAddOns.php @@ -125,8 +125,10 @@ public function get_item_data( $addon_data, $addon, $cart_item ): array { // Quantity/multiplier add on needs to be split, calculated, then multiplied by input value. $price = $this->multi_currency->get_price( $addon['price'] / $addon['value'], 'product' ) * $addon['value']; } - $price = \WC_Product_Addons_Helper::get_product_addon_price_for_display( $price, $cart_item['data'] ); - $name .= ' (' . wc_price( $price ) . ')'; + if ( class_exists( '\WC_Product_Addons_Helper' ) ) { + $price = \WC_Product_Addons_Helper::get_product_addon_price_for_display( $price, $cart_item['data'] ); + $name .= ' (' . wc_price( $price ) . ')'; + } } else { // Get the percentage cost in the currency in use, and set the meta data on the product that the value was converted. $_product = wc_get_product( $cart_item['product_id'] ); @@ -245,12 +247,14 @@ public function order_line_item_meta( array $meta_data, array $addon, \WC_Order_ // Convert all others. $addon_price = $this->multi_currency->get_price( $addon['price'], 'product' ); } - $price = html_entity_decode( - wp_strip_all_tags( wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $values['data'] ) ) ), - ENT_QUOTES, - get_bloginfo( 'charset' ) - ); - $addon['name'] .= ' (' . $price . ')'; + if ( class_exists( '\WC_Product_Addons_Helper' ) ) { + $price = html_entity_decode( + wp_strip_all_tags( wc_price( \WC_Product_Addons_Helper::get_product_addon_price_for_display( $addon_price, $values['data'] ) ) ), + ENT_QUOTES, + get_bloginfo( 'charset' ) + ); + $addon['name'] .= ' (' . $price . ')'; + } } if ( 'custom_price' === $addon['field_type'] ) { diff --git a/includes/woopay/class-woopay-adapted-extensions.php b/includes/woopay/class-woopay-adapted-extensions.php index 0180525f726..5b9c5c8643e 100644 --- a/includes/woopay/class-woopay-adapted-extensions.php +++ b/includes/woopay/class-woopay-adapted-extensions.php @@ -171,7 +171,7 @@ public function get_extension_data() { ]; } - if ( $this->is_affiliate_for_woocommerce_enabled() ) { + if ( $this->is_affiliate_for_woocommerce_enabled() && function_exists( 'afwc_get_referrer_id' ) ) { /** * Suppress psalm warning. * @@ -207,12 +207,14 @@ public function update_order_extension_data( $order_id ) { ) { $affiliate_id = (int) wc_clean( wp_unslash( $_GET['affiliate'] ) ); // phpcs:ignore WordPress.Security.NonceVerification - // phpcs:ignore - /** - * @psalm-suppress UndefinedClass - */ - $affiliate_api = \AFWC_API::get_instance(); - $affiliate_api->track_conversion( $order_id, $affiliate_id, '', [ 'is_affiliate_eligible' => true ] ); + if ( class_exists( '\AFWC_API' ) ) { + // phpcs:ignore + /** + * @psalm-suppress UndefinedClass + */ + $affiliate_api = \AFWC_API::get_instance(); + $affiliate_api->track_conversion( $order_id, $affiliate_id, '', [ 'is_affiliate_eligible' => true ] ); + } } } @@ -272,7 +274,10 @@ class_exists( '\AutomateWoo\Referrals\Referral_Manager' ) && * @return string|null */ private function get_automate_woo_advocate_id_from_cookie() { - $advocate_from_key_cookie = \AutomateWoo\Referrals\Referral_Manager::get_advocate_key_from_cookie(); - return $advocate_from_key_cookie ? $advocate_from_key_cookie->get_advocate_id() : null; + if ( class_exists( '\AutomateWoo\Referrals\Referral_Manager' ) ) { + $advocate_from_key_cookie = \AutomateWoo\Referrals\Referral_Manager::get_advocate_key_from_cookie(); + return $advocate_from_key_cookie ? $advocate_from_key_cookie->get_advocate_id() : null; + } + return null; } } diff --git a/includes/woopay/class-woopay-store-api-token.php b/includes/woopay/class-woopay-store-api-token.php index 0601b410350..b8dfb72a433 100644 --- a/includes/woopay/class-woopay-store-api-token.php +++ b/includes/woopay/class-woopay-store-api-token.php @@ -55,6 +55,7 @@ public function get_args() { * @psalm-suppress UndefinedMethod */ public function get_cart_token() { + // @phpstan-ignore-next-line. return parent::get_cart_token(); } } diff --git a/src/Internal/Payment/State/AbstractPaymentState.php b/src/Internal/Payment/State/AbstractPaymentState.php index 19649b7eadb..942fec96ca2 100644 --- a/src/Internal/Payment/State/AbstractPaymentState.php +++ b/src/Internal/Payment/State/AbstractPaymentState.php @@ -79,6 +79,7 @@ public function get_context(): PaymentContext { * @throws PaymentRequestException When data is not available or invalid. */ public function start_processing( PaymentRequest $request ) { + // @phpstan-ignore-next-line $this->throw_unavailable_method_exception( __METHOD__ ); } @@ -92,6 +93,7 @@ public function start_processing( PaymentRequest $request ) { * @throws StateTransitionException */ public function complete_processing() { + // @phpstan-ignore-next-line $this->throw_unavailable_method_exception( __METHOD__ ); } // phpcs:enable Squiz.Commenting.FunctionComment.InvalidNoReturn @@ -103,15 +105,15 @@ public function complete_processing() { * This method should only be called whenever the process is ready to transition * to the next state, as each new state will be considered the payment's latest one. * - * @template ConcreteState - * @param class-string | string $state_class The class of the state to crate. + * @template ConcreteState of AbstractPaymentState + * @param class-string $state_class The class of the state to create. * - * @return AbstractPaymentState | ConcreteState + * @return ConcreteState The generated payment state instance. * * @throws StateTransitionException In case the new state could not be created. * @throws ContainerException When the dependency container cannot instantiate the state. */ - protected function create_state( string $state_class ) { + protected function create_state( /*class-string*/ $state_class ): AbstractPaymentState { $state = $this->state_factory->create_state( $state_class, $this->context ); // This is where logging will be added. diff --git a/src/Internal/Payment/State/StateFactory.php b/src/Internal/Payment/State/StateFactory.php index fa9554f4e73..7d0b77c375c 100644 --- a/src/Internal/Payment/State/StateFactory.php +++ b/src/Internal/Payment/State/StateFactory.php @@ -38,15 +38,15 @@ public function __construct( Container $container ) { /** * Creates a new state based on class name. * - * @template ConcreteState - * @param class-string | string $state_class Name of the state class. - * @param PaymentContext $context Context for the new state. + * @template ConcreteState of AbstractPaymentState + * @param class-string $state_class Name of the state class. + * @param PaymentContext $context Context for the new state. * - * @return AbstractPaymentState | ConcreteState The generated payment state instance. + * @return ConcreteState The generated payment state instance. * @throws ContainerException When the dependency container cannot instantiate the state. * @throws StateTransitionException When the class name is not a state. */ - public function create_state( string $state_class, PaymentContext $context ): AbstractPaymentState { + public function create_state( /*class-string*/ $state_class, PaymentContext $context ): AbstractPaymentState { if ( ! is_subclass_of( $state_class, AbstractPaymentState::class ) ) { throw new StateTransitionException( esc_html( diff --git a/tests/qit/README.md b/tests/qit/README.md index a1d2e951709..896cc9b0ab9 100644 --- a/tests/qit/README.md +++ b/tests/qit/README.md @@ -1,18 +1,21 @@ ## WooCommerce Payments QIT tests -We currently only use the security tests from the [QIT toolkit](https://woocommerce.github.io/qit-documentation/#/) and these can be run locally. +We currently only use the security tests from the [QIT toolkit](https://qit.woo.com/docs/) and these can be run locally. #### Setup and running - Create `local.env` inside the `tests/qit/config/` directory by copying the variables from `default.env`. - To get the actual values for local config, refer to this [secret store](https://mc.a8c.com/secret-store/?secret_id=11043) link. - Once configured, the first time you run the `npm` command, it should create a local auth file which will be used for subsequent runs. -- For running, use: +- Currently, two types of tests are available through the `npm` command: Security and PHPStan tests. PHPStan tests can also be run against the local development build. +- For running, use one of the following commands based on your requirements: ``` - npm run test:qit + npm run test:qit-security + npm run test:qit-phpstan + npm run test:qit-phpstan-local ``` -- The command uses the `build:release` command to create `woocommerce-payments.zip` at the root of the directory which is then uploaded and used for the QIT tests. +- The commands use the `build:release` to create `woocommerce-payments.zip` at the root of the directory which is then uploaded and used for the QIT tests. #### Analysing results From 2f30d7e47983bb672ef8e9e32ca76c168d000b7d Mon Sep 17 00:00:00 2001 From: Niels Lange Date: Tue, 4 Jun 2024 10:41:17 +0700 Subject: [PATCH 10/52] Declare compatibility with the Cart and Checkout blocks (#8890) --- ...update-declare-compatibility-width-cart-and-checkout-block | 4 ++++ woocommerce-payments.php | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog/update-declare-compatibility-width-cart-and-checkout-block diff --git a/changelog/update-declare-compatibility-width-cart-and-checkout-block b/changelog/update-declare-compatibility-width-cart-and-checkout-block new file mode 100644 index 00000000000..dff8698007d --- /dev/null +++ b/changelog/update-declare-compatibility-width-cart-and-checkout-block @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Declare compatibility with the Cart and Checkout blocks. diff --git a/woocommerce-payments.php b/woocommerce-payments.php index cdb84deb13a..46546f65539 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -423,7 +423,8 @@ function register_woopay_extension() { 'before_woocommerce_init', function () { if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) { - \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', 'woocommerce-payments/woocommerce-payments.php', true ); + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true ); + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); } } ); From 0fac547f86913073d1eadf39880cd47ef719c5a7 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:30:30 +1000 Subject: [PATCH 11/52] Skip flaky playwright e2e test: `payment gateways disable confirmation` (#8899) --- ...able-flaky-e2e-test-payment-gateways-disable-confirmation | 5 +++++ .../merchant/merchant-payment-gateways-confirmation.spec.ts | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelog/dev-8875-disable-flaky-e2e-test-payment-gateways-disable-confirmation diff --git a/changelog/dev-8875-disable-flaky-e2e-test-payment-gateways-disable-confirmation b/changelog/dev-8875-disable-flaky-e2e-test-payment-gateways-disable-confirmation new file mode 100644 index 00000000000..7d97fa86fc3 --- /dev/null +++ b/changelog/dev-8875-disable-flaky-e2e-test-payment-gateways-disable-confirmation @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Not user-facing: disable flaky playwright e2e test 'payment gateways disable confirmation' + + diff --git a/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts b/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts index 6197cfa5fcb..f4981c22798 100644 --- a/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts +++ b/tests/e2e-pw/specs/merchant/merchant-payment-gateways-confirmation.spec.ts @@ -8,7 +8,8 @@ import { test, expect, Page } from '@playwright/test'; */ import { useMerchant } from '../../utils/helpers'; -test.describe( 'payment gateways disable confirmation', () => { +// Skipping the test for now as it is flaky on GH action runs. See #8875. +test.skip( 'payment gateways disable confirmation', () => { useMerchant(); const getToggle = ( page: Page ) => From b73ef0649a6b98c68607de329567ef7cb0a856cb Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 4 Jun 2024 16:24:12 +0800 Subject: [PATCH 12/52] Improve the transition from the WCPay KYC to the WC Admin Payments Task (#8893) --- .../update-redirect-to-payment-screen-for-wcadmin-task | 4 ++++ includes/class-wc-payments-account.php | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 changelog/update-redirect-to-payment-screen-for-wcadmin-task diff --git a/changelog/update-redirect-to-payment-screen-for-wcadmin-task b/changelog/update-redirect-to-payment-screen-for-wcadmin-task new file mode 100644 index 00000000000..f9c4a0dbe11 --- /dev/null +++ b/changelog/update-redirect-to-payment-screen-for-wcadmin-task @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Improve the transition from the WCPay KYC to the WC Admin Payments Task diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index 79b11b6646b..4b4a2fec01c 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -1235,9 +1235,13 @@ public static function get_connect_url( $wcpay_connect_from = '1' ) { /** * Payments task page url * + * @deprecated 7.8.0 + * * @return string payments task page url */ public static function get_payments_task_page_url() { + wc_deprecated_function( __FUNCTION__, '7.8.0' ); + return add_query_arg( [ 'page' => 'wc-admin', @@ -1396,11 +1400,9 @@ private function get_onboarding_return_url( $wcpay_connect_from ) { ); } - // If connection originated on the WCADMIN payment task page, return there. - // else goto the overview page, since now it is GA (earlier it was redirected to plugin settings page). + // Custom return URL for the connect page based on the source. + // Default goto the overview page, since now it is GA (earlier it was redirected to plugin settings page). switch ( $wcpay_connect_from ) { - case 'WCADMIN_PAYMENT_TASK': - return static::get_payments_task_page_url(); case 'WC_SUBSCRIPTIONS_TABLE': return admin_url( add_query_arg( [ 'post_type' => 'shop_subscription' ], 'edit.php' ) ); default: From 8aa455c724e3eeaa7a31bbe536b6266296b0592a Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 4 Jun 2024 13:08:01 -0500 Subject: [PATCH 13/52] Disable WooPay for suspended and rejected accounts (#8857) --- ...disable-woopay-rejected-suspended-accounts | 4 ++ includes/class-wc-payments-features.php | 10 ++++- includes/class-wc-payments.php | 7 ++- .../unit/test-class-wc-payments-features.php | 44 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 changelog/as-disable-woopay-rejected-suspended-accounts diff --git a/changelog/as-disable-woopay-rejected-suspended-accounts b/changelog/as-disable-woopay-rejected-suspended-accounts new file mode 100644 index 00000000000..5c5d37f068f --- /dev/null +++ b/changelog/as-disable-woopay-rejected-suspended-accounts @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Disable WooPay for suspended and rejected accounts. diff --git a/includes/class-wc-payments-features.php b/includes/class-wc-payments-features.php index 568ee184b5a..42e2a19e38b 100644 --- a/includes/class-wc-payments-features.php +++ b/includes/class-wc-payments-features.php @@ -210,7 +210,15 @@ public static function is_woopay_eligible() { // read directly from cache, ignore cache expiration check. $account = WC_Payments::get_database_cache()->get( WCPay\Database_Cache::ACCOUNT_KEY, true ); - return is_array( $account ) && ( $account['platform_checkout_eligible'] ?? false ); + + $is_account_rejected = WC_Payments::get_account_service()->is_account_rejected(); + + $is_account_under_review = WC_Payments::get_account_service()->is_account_under_review(); + + return is_array( $account ) + && ( $account['platform_checkout_eligible'] ?? false ) + && ! $is_account_rejected + && ! $is_account_under_review; } /** diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 04b2e517c1f..d640cc30539 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -570,7 +570,9 @@ public static function init() { // To avoid register the same hooks twice. wcpay_get_container()->get( \WCPay\Internal\Service\DuplicatePaymentPreventionService::class )->init_hooks(); - self::maybe_register_woopay_hooks(); + // Defer registering the WooPay hooks. Later on, $wp_rewrite is used and causes a fatal error every time the account cache is refreshed, + // given that $wp_rewrite is defined right after the `plugins_loaded` action is fired. See #8857. + add_action( 'setup_theme', [ __CLASS__, 'maybe_register_woopay_hooks' ] ); self::$apple_pay_registration = new WC_Payments_Apple_Pay_Registration( self::$api_client, self::$account, self::get_gateway() ); self::$apple_pay_registration->init_hooks(); @@ -1502,6 +1504,7 @@ public static function is_network_saved_cards_enabled() { /** * Registers woopay hooks if the woopay feature flag is enabled. + * Removes WooPay webhooks if the merchant is not eligible. * * @return void */ @@ -1551,6 +1554,8 @@ function ( $container ) { } new WooPay_Order_Status_Sync( self::$api_client, self::$account ); + } else { + WooPay_Order_Status_Sync::remove_webhook(); } } diff --git a/tests/unit/test-class-wc-payments-features.php b/tests/unit/test-class-wc-payments-features.php index a27ff2c432f..4e6d2258c13 100644 --- a/tests/unit/test-class-wc-payments-features.php +++ b/tests/unit/test-class-wc-payments-features.php @@ -18,6 +18,13 @@ class WC_Payments_Features_Test extends WCPAY_UnitTestCase { */ protected $mock_cache; + /** + * Mock WC_Payments_Account. + * + * @var WC_Payments_Account|MockObject + */ + private $mock_wcpay_account; + const FLAG_OPTION_NAME_TO_FRONTEND_KEY_MAPPING = [ '_wcpay_feature_customer_multi_currency' => 'multiCurrency', '_wcpay_feature_documents' => 'documents', @@ -29,6 +36,17 @@ public function set_up() { $this->_cache = WC_Payments::get_database_cache(); $this->mock_cache = $this->createMock( WCPay\Database_Cache::class ); WC_Payments::set_database_cache( $this->mock_cache ); + + // Mock the WCPay Account class to make sure the account is not restricted by default. + $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $this->mock_wcpay_account + ->method( 'is_account_rejected' ) + ->willReturn( false ); + $this->mock_wcpay_account + ->method( 'is_account_under_review' ) + ->willReturn( false ); + + WC_Payments::set_account_service( $this->mock_wcpay_account ); } public function tear_down() { @@ -91,6 +109,32 @@ public function test_is_woopay_eligible_returns_false() { $this->assertFalse( WC_Payments_Features::is_woopay_eligible() ); } + public function test_is_woopay_eligible_when_account_is_suspended_returns_false() { + $mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $mock_wcpay_account + ->method( 'is_account_under_review' ) + ->willReturn( true ); + + WC_Payments::set_account_service( $mock_wcpay_account ); + + $this->mock_cache->method( 'get' )->willReturn( [ 'platform_checkout_eligible' => true ] ); + + $this->assertFalse( WC_Payments_Features::is_woopay_eligible() ); + } + + public function test_is_woopay_eligible_when_account_is_rejected_returns_false() { + $mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); + $mock_wcpay_account + ->method( 'is_account_rejected' ) + ->willReturn( true ); + + WC_Payments::set_account_service( $mock_wcpay_account ); + + $this->mock_cache->method( 'get' )->willReturn( [ 'platform_checkout_eligible' => true ] ); + + $this->assertFalse( WC_Payments_Features::is_woopay_eligible() ); + } + public function test_is_documents_section_enabled_returns_true_when_flag_is_true() { $this->mock_cache->method( 'get' )->willReturn( [ 'is_documents_enabled' => true ] ); $this->assertTrue( WC_Payments_Features::is_documents_section_enabled() ); From 282dfe64a53016875882cf22f7a8eb039858aa37 Mon Sep 17 00:00:00 2001 From: Cvetan Cvetanov Date: Wed, 5 Jun 2024 16:56:22 +0300 Subject: [PATCH 14/52] Add survey modal on WooPayments deactivation (#8757) --- .../add-1527-survey-modal-on-deactivation | 4 + .../plugins-page/deactivation-survey/index.js | 50 ++++++++ .../deactivation-survey/style.scss | 38 ++++++ client/plugins-page/index.js | 108 ++++++++++++++++++ includes/admin/class-wc-payments-admin.php | 57 +++++++++ includes/class-wc-payments.php | 1 + .../plugins-page/plugins-page-wrapper.php | 24 ++++ webpack/shared.js | 1 + 8 files changed, 283 insertions(+) create mode 100644 changelog/add-1527-survey-modal-on-deactivation create mode 100644 client/plugins-page/deactivation-survey/index.js create mode 100644 client/plugins-page/deactivation-survey/style.scss create mode 100644 client/plugins-page/index.js create mode 100644 templates/plugins-page/plugins-page-wrapper.php diff --git a/changelog/add-1527-survey-modal-on-deactivation b/changelog/add-1527-survey-modal-on-deactivation new file mode 100644 index 00000000000..d2f860f88ed --- /dev/null +++ b/changelog/add-1527-survey-modal-on-deactivation @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add a feedback survey modal upon deactivation. diff --git a/client/plugins-page/deactivation-survey/index.js b/client/plugins-page/deactivation-survey/index.js new file mode 100644 index 00000000000..faa4eaf2228 --- /dev/null +++ b/client/plugins-page/deactivation-survey/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import React, { useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { Modal } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; +import Loadable from 'wcpay/components/loadable'; +import WooPaymentsIcon from 'assets/images/woopayments.svg?asset'; + +const PluginDisableSurvey = ( { onRequestClose } ) => { + const [ isLoading, setIsLoading ] = useState( true ); + + return ( + + } + isDismissible={ true } + shouldCloseOnClickOutside={ false } // Should be false because of the iframe. + shouldCloseOnEsc={ true } + onRequestClose={ onRequestClose } + className="woopayments-disable-survey" + > + +