From c9ebf9382dddc40d0cd5b5ce61c129a17a120c34 Mon Sep 17 00:00:00 2001 From: Leonardo Lopes de Albuquerque Date: Thu, 5 Dec 2024 13:50:23 -0300 Subject: [PATCH 01/11] Add WooPay clickwrap support (#9752) --- .../add-2253-clickwrap-terms-and-conditions | 4 ++ .../test/woopay-settings.test.js | 2 +- .../woopay-settings.js | 2 +- includes/woopay/class-woopay-session.php | 54 +++++++++++++++---- 4 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 changelog/add-2253-clickwrap-terms-and-conditions diff --git a/changelog/add-2253-clickwrap-terms-and-conditions b/changelog/add-2253-clickwrap-terms-and-conditions new file mode 100644 index 00000000000..ac0a4ece4b7 --- /dev/null +++ b/changelog/add-2253-clickwrap-terms-and-conditions @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Clickwrap terms and conditions support on WooPay diff --git a/client/settings/express-checkout-settings/test/woopay-settings.test.js b/client/settings/express-checkout-settings/test/woopay-settings.test.js index d7ce1018146..abcac0754ce 100644 --- a/client/settings/express-checkout-settings/test/woopay-settings.test.js +++ b/client/settings/express-checkout-settings/test/woopay-settings.test.js @@ -149,7 +149,7 @@ describe( 'WooPaySettings', () => { // confirm settings headings expect( screen.queryByRole( 'heading', { - name: 'Policies and custom text', + name: 'Checkout policies', } ) ).toBeInTheDocument(); diff --git a/client/settings/express-checkout-settings/woopay-settings.js b/client/settings/express-checkout-settings/woopay-settings.js index 0ba3f8f7b92..f2a4931f9f6 100644 --- a/client/settings/express-checkout-settings/woopay-settings.js +++ b/client/settings/express-checkout-settings/woopay-settings.js @@ -255,7 +255,7 @@ const WooPaySettings = ( { section } ) => {

{ __( - 'Policies and custom text', + 'Checkout policies', 'woocommerce-payments' ) }

diff --git a/includes/woopay/class-woopay-session.php b/includes/woopay/class-woopay-session.php index dce1878265e..5c799ee0ead 100644 --- a/includes/woopay/class-woopay-session.php +++ b/includes/woopay/class-woopay-session.php @@ -898,9 +898,10 @@ private static function get_formatted_custom_message() { */ private static function get_option_fields_status() { // Shortcode checkout options. - $company = get_option( 'woocommerce_checkout_company_field', 'optional' ); - $address_2 = get_option( 'woocommerce_checkout_address_2_field', 'optional' ); - $phone = get_option( 'woocommerce_checkout_phone_field', 'required' ); + $company = get_option( 'woocommerce_checkout_company_field', 'optional' ); + $address_2 = get_option( 'woocommerce_checkout_address_2_field', 'optional' ); + $phone = get_option( 'woocommerce_checkout_phone_field', 'required' ); + $terms_checkbox = ! empty( get_option( 'woocommerce_terms_page_id', null ) ); // Blocks checkout options. To get the blocks checkout options, we need // to parse the checkout page content because the options are stored @@ -910,9 +911,10 @@ private static function get_option_fields_status() { if ( empty( $checkout_page ) ) { return [ - 'company' => $company, - 'address_2' => $address_2, - 'phone' => $phone, + 'company' => $company, + 'address_2' => $address_2, + 'phone' => $phone, + 'terms_checkbox' => $terms_checkbox, ]; } @@ -947,12 +949,46 @@ private static function get_option_fields_status() { if ( isset( $checkout_block_attrs['showPhoneField'] ) && false === $checkout_block_attrs['showPhoneField'] ) { $phone = 'hidden'; } + + $fields_block = self::get_inner_block( $checkout_page_blocks[ $checkout_block_index ], 'woocommerce/checkout-fields-block' ); + $terms_block = self::get_inner_block( $fields_block, 'woocommerce/checkout-terms-block' ); + $terms_checkbox = isset( $terms_block['attrs']['checkbox'] ) && $terms_block['attrs']['checkbox']; } return [ - 'company' => $company, - 'address_2' => $address_2, - 'phone' => $phone, + 'company' => $company, + 'address_2' => $address_2, + 'phone' => $phone, + 'terms_checkbox' => $terms_checkbox, ]; } + + /** + * Searches for an inner block with the given name. + * + * @param array $current_block A block that contains child blocks. + * @param string $inner_block_name The name of a child block. + * @return array|null + */ + private static function get_inner_block( $current_block, $inner_block_name ) { + + if ( ! isset( $current_block['innerBlocks'] ) ) { + return; + } + + $inner_block_index = array_search( + $inner_block_name, + array_column( + $current_block['innerBlocks'], + 'blockName' + ), + true + ); + + if ( ! isset( $current_block['innerBlocks'][ $inner_block_index ] ) ) { + return; + } + + return $current_block['innerBlocks'][ $inner_block_index ]; + } } From 907c23822c8b66d797f4323ee9f03a3d0e24002f Mon Sep 17 00:00:00 2001 From: Mike Moore Date: Thu, 5 Dec 2024 14:45:36 -0500 Subject: [PATCH 02/11] Fallback to card payment type in get_payment_method_types (#9829) --- changelog/8969-fallback-to-card-payment-type | 5 ++ includes/class-wc-payment-gateway-wcpay.php | 6 +-- .../test-class-upe-split-payment-gateway.php | 53 ------------------- .../test-class-wc-payment-gateway-wcpay.php | 32 +++++------ 4 files changed, 20 insertions(+), 76 deletions(-) create mode 100644 changelog/8969-fallback-to-card-payment-type diff --git a/changelog/8969-fallback-to-card-payment-type b/changelog/8969-fallback-to-card-payment-type new file mode 100644 index 00000000000..ee66dbfa7e7 --- /dev/null +++ b/changelog/8969-fallback-to-card-payment-type @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Small change to payment method types fallback scenario. + + diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 02d26bbcb4c..3623aded21a 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1571,7 +1571,6 @@ public function process_payment_for_order( $cart, $payment_information, $schedul throw new Exception( WC_Payments_Utils::get_filtered_error_message( $e ) ); } - $payment_methods = $this->get_payment_method_types( $payment_information ); // The sanitize_user call here is deliberate: it seems the most appropriate sanitization function // for a string that will only contain latin alphanumeric characters and underscores. // phpcs:ignore WordPress.Security.NonceVerification.Missing @@ -1602,6 +1601,8 @@ public function process_payment_for_order( $cart, $payment_information, $schedul } if ( empty( $intent ) ) { + $payment_methods = $this->get_payment_method_types( $payment_information ); + $request = Create_And_Confirm_Intention::create(); $request->set_amount( $converted_amount ); $request->set_currency_code( $currency ); @@ -2126,9 +2127,6 @@ public function get_payment_method_types( $payment_information ): array { $order = $payment_information->get_order(); $order_id = $order instanceof WC_Order ? $order->get_id() : null; $payment_methods = $this->get_payment_methods_from_gateway_id( $token->get_gateway_id(), $order_id ); - } else { - // Final fallback case, if all else fails. - $payment_methods = WC_Payments::get_gateway()->get_payment_method_ids_enabled_at_checkout( null, true ); } return $payment_methods; diff --git a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php index 9d6185387f6..a3284a840ce 100644 --- a/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php +++ b/tests/unit/payment-methods/test-class-upe-split-payment-gateway.php @@ -1140,59 +1140,6 @@ public function test_get_payment_methods_without_request_context() { $this->assertSame( [ Payment_Method::CARD ], $payment_methods ); } - /** - * Test get_payment_method_types without post request context or saved token. - * - * @return void - */ - public function test_get_payment_methods_without_request_context_or_token() { - $mock_upe_gateway = $this->getMockBuilder( WC_Payment_Gateway_WCPay::class ) - ->setConstructorArgs( - [ - $this->mock_api_client, - $this->mock_wcpay_account, - $this->mock_customer_service, - $this->mock_token_service, - $this->mock_action_scheduler_service, - $this->mock_payment_methods[ Payment_Method::CARD ], - $this->mock_payment_methods, - $this->order_service, - $this->mock_dpps, - $this->mock_localization_service, - $this->mock_fraud_service, - $this->mock_duplicates_detection_service, - $this->mock_rate_limiter, - ] - ) - ->setMethods( - [ - 'get_payment_methods_from_gateway_id', - 'get_payment_method_ids_enabled_at_checkout', - ] - ) - ->getMock(); - - $payment_information = new Payment_Information( 'pm_mock' ); - - unset( $_POST['payment_method'] ); // phpcs:ignore WordPress.Security.NonceVerification - - $gateway = WC_Payments::get_gateway(); - WC_Payments::set_gateway( $mock_upe_gateway ); - - $mock_upe_gateway->expects( $this->never() ) - ->method( 'get_payment_methods_from_gateway_id' ); - - $mock_upe_gateway->expects( $this->once() ) - ->method( 'get_payment_method_ids_enabled_at_checkout' ) - ->willReturn( [ Payment_Method::CARD ] ); - - $payment_methods = $mock_upe_gateway->get_payment_method_types( $payment_information ); - - $this->assertSame( [ Payment_Method::CARD ], $payment_methods ); - - WC_Payments::set_gateway( $gateway ); - } - /** * Test get_payment_methods_from_gateway_id function with UPE enabled. * diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 4b7bf857997..6da6fbc85c0 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -1239,21 +1239,6 @@ public function test_get_payment_methods_without_post_request_context() { $this->assertSame( [ Payment_Method::CARD ], $payment_methods ); } - public function test_get_payment_methods_without_request_context_or_token() { - $payment_information = new Payment_Information( 'pm_mock' ); - - unset( $_POST['payment_method'] ); // phpcs:ignore WordPress.Security.NonceVerification - - $gateway = WC_Payments::get_gateway(); - WC_Payments::set_gateway( $this->card_gateway ); - - $payment_methods = $this->card_gateway->get_payment_method_types( $payment_information ); - - $this->assertSame( [ Payment_Method::CARD ], $payment_methods ); - - WC_Payments::set_gateway( $gateway ); - } - public function test_get_payment_methods_from_gateway_id_upe() { WC_Helper_Order::create_order(); @@ -2542,7 +2527,7 @@ public function test_process_payment_for_order_not_from_request() { $order->add_payment_token( $token ); $order->save(); - $pi = new Payment_Information( 'pm_test', $order, null, null, null, null, null, '', 'card' ); + $pi = new Payment_Information( 'pm_test', $order, null, $token, null, null, null, '', 'card' ); $request = $this->mock_wcpay_request( Create_And_Confirm_Intention::class ); $request->expects( $this->once() ) @@ -3086,7 +3071,10 @@ public function test_process_payment_caches_mimimum_amount_and_displays_error_up ->method( 'get_customer_id_by_user_id' ) ->will( $this->returnValue( $customer ) ); - $_POST = [ 'wcpay-payment-method' => $pm = 'pm_mock' ]; + $_POST = [ + 'wcpay-payment-method' => $pm = 'pm_mock', + 'payment_method' => 'woocommerce_payments', + ]; $this->get_fraud_prevention_service_mock() ->expects( $this->once() ) @@ -3922,7 +3910,10 @@ public function test_process_payment_rate_limiter_enabled_throw_exception() { public function test_process_payment_returns_correct_redirect() { $order = WC_Helper_Order::create_order(); - $_POST = [ 'wcpay-payment-method' => 'pm_mock' ]; + $_POST = [ + 'wcpay-payment-method' => 'pm_mock', + 'payment_method' => 'woocommerce_payments', + ]; $this->mock_wcpay_request( Create_And_Confirm_Intention::class, 1 ) ->expects( $this->once() ) @@ -3945,7 +3936,10 @@ public function test_process_payment_returns_correct_redirect() { public function test_process_payment_returns_correct_redirect_when_using_payment_request() { $order = WC_Helper_Order::create_order(); $_POST['payment_request_type'] = 'google_pay'; - $_POST = [ 'wcpay-payment-method' => 'pm_mock' ]; + $_POST = [ + 'wcpay-payment-method' => 'pm_mock', + 'payment_method' => 'woocommerce_payments', + ]; $this->mock_wcpay_request( Create_And_Confirm_Intention::class, 1 ) ->expects( $this->once() ) From 007d6d6da6a665dc991a04657d08cf9a098418d5 Mon Sep 17 00:00:00 2001 From: Francesco Date: Fri, 6 Dec 2024 12:39:20 +0100 Subject: [PATCH 03/11] fix: undefined `$cart_contains_subscription` (#9893) --- changelog/frosso-patch-1 | 4 ++++ includes/class-wc-payments.php | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 changelog/frosso-patch-1 diff --git a/changelog/frosso-patch-1 b/changelog/frosso-patch-1 new file mode 100644 index 00000000000..e3812625698 --- /dev/null +++ b/changelog/frosso-patch-1 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +fix: undefined $cart_contains_subscription diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 103ead1e52d..17300478794 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -1876,12 +1876,13 @@ public static function init_woopay() { public static function load_stripe_bnpl_site_messaging() { // The messaging element shall not be shown for subscription products. // As we are not too deep into subscriptions API, we follow simplistic approach for now. - $is_subscription = false; - $are_subscriptions_enabled = class_exists( 'WC_Subscriptions' ) || class_exists( 'WC_Subscriptions_Core_Plugin' ); + $is_subscription = false; + $cart_contains_subscription = false; + $are_subscriptions_enabled = class_exists( 'WC_Subscriptions' ) || class_exists( 'WC_Subscriptions_Core_Plugin' ); if ( $are_subscriptions_enabled ) { - global $product; - $is_subscription = $product && WC_Subscriptions_Product::is_subscription( $product ); - $cart_contains_subscription = is_cart() && WC_Subscriptions_Cart::cart_contains_subscription(); + global $product; + $is_subscription = $product && WC_Subscriptions_Product::is_subscription( $product ); + $cart_contains_subscription = is_cart() && WC_Subscriptions_Cart::cart_contains_subscription(); } if ( ! $is_subscription && ! $cart_contains_subscription ) { From 81ee27e065caf71a4d8e48ce2b69225d635607da Mon Sep 17 00:00:00 2001 From: Dan Paun <82826872+dpaun1985@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:52:26 +0200 Subject: [PATCH 04/11] Implement gateway method to retrieve recommended PMs (#9825) Co-authored-by: Dan Paun Co-authored-by: Vlad Olaru Co-authored-by: Vlad Olaru --- changelog/add-9690-recommended-pm | 4 + includes/class-database-cache.php | 1 + includes/class-wc-payment-gateway-wcpay.php | 31 ++++++- includes/class-wc-payments-account.php | 63 ++++++++++++++ .../class-wc-payments-onboarding-service.php | 29 +++++++ .../class-wc-payments-api-client.php | 60 +++++++++++++ .../test-class-wc-payment-gateway-wcpay.php | 51 +++++++++++ tests/unit/test-class-wc-payments-account.php | 85 +++++++++++++++++++ 8 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 changelog/add-9690-recommended-pm diff --git a/changelog/add-9690-recommended-pm b/changelog/add-9690-recommended-pm new file mode 100644 index 00000000000..2d615350daa --- /dev/null +++ b/changelog/add-9690-recommended-pm @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Implement gateway method to retrieve recommended payment method. diff --git a/includes/class-database-cache.php b/includes/class-database-cache.php index 1e285be59ab..e29bdbfc374 100644 --- a/includes/class-database-cache.php +++ b/includes/class-database-cache.php @@ -20,6 +20,7 @@ class Database_Cache implements MultiCurrencyCacheInterface { const BUSINESS_TYPES_KEY = 'wcpay_business_types_data'; const PAYMENT_PROCESS_FACTORS_KEY = 'wcpay_payment_process_factors'; const FRAUD_SERVICES_KEY = 'wcpay_fraud_services_data'; + const RECOMMENDED_PAYMENT_METHODS = 'wcpay_recommended_payment_methods'; /** * Refresh during AJAX calls is avoided, but white-listing diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 3623aded21a..0f53c3dde42 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -4498,11 +4498,40 @@ public function find_duplicates() { return $this->duplicate_payment_methods_detection_service->find_duplicates(); } + /** + * Get the recommended payment methods list. + * + * @param string $country_code Optional. The business location country code. Provide a 2-letter ISO country code. + * If not provided, the account country will be used if the account is connected. + * Otherwise, the store's base country will be used. + * + * @return array List of recommended payment methods for the given country. + * Empty array if there are no recommendations available. + * Each item in the array should be an associative array with at least the following entries: + * - @string id: The payment method ID. + * - @string title: The payment method title/name. + * - @bool enabled: Whether the payment method is enabled. + * - @int order/priority: The order/priority of the payment method. + */ + public function get_recommended_payment_methods( string $country_code = '' ): array { + if ( empty( $country_code ) ) { + // If the account is connected, use the account country. + if ( $this->account->is_provider_connected() ) { + $country_code = $this->get_account_country(); + } else { + // If the account is not connected, use the store's base country. + $country_code = WC()->countries->get_base_country(); + } + } + + return $this->account->get_recommended_payment_methods( $country_code ); + } + /** * Determine whether redirection is needed for the non-card UPE payment method. * * @param array $payment_methods The list of payment methods used for the order processing, usually consists of one method only. - * @return boolean True if the arrray consist of only one payment method which is not a card. False otherwise. + * @return boolean True if the array consist of only one payment method which is not a card. False otherwise. */ private function upe_needs_redirection( $payment_methods ) { return 1 === count( $payment_methods ) && 'card' !== $payment_methods[0]; diff --git a/includes/class-wc-payments-account.php b/includes/class-wc-payments-account.php index e884d582ac8..85b0f336263 100644 --- a/includes/class-wc-payments-account.php +++ b/includes/class-wc-payments-account.php @@ -665,6 +665,69 @@ public function get_supported_countries(): array { return WC_Payments_Utils::supported_countries(); } + /** + * Get the account recommended payment methods to use during onboarding. + * + * @param string $country_code The account's business location country code. Provide a 2-letter ISO country code. + * + * @return array List of recommended payment methods for the given country. + * Empty array if there are no recommendations, we failed to retrieve recommendations, + * or the country is not supported by WooPayments. + */ + public function get_recommended_payment_methods( string $country_code ): array { + // Return early if the country is not supported. + if ( ! array_key_exists( $country_code, $this->get_supported_countries() ) ) { + return []; + } + + // We use the locale for the current user (defaults to the site locale). + $recommended_pms = $this->onboarding_service->get_recommended_payment_methods( $country_code, get_user_locale() ); + $recommended_pms = is_array( $recommended_pms ) ? array_values( $recommended_pms ) : []; + + // Validate the recommended payment methods. + // Each must have an ID and a title. + $recommended_pms = array_filter( + $recommended_pms, + function ( $pm ) { + return isset( $pm['id'] ) && isset( $pm['title'] ); + } + ); + + // Standardize/normalize. + // Determine if the payment method should be recommended as enabled. + $recommended_pms = array_map( + function ( $pm ) { + if ( ! isset( $pm['enabled'] ) ) { + // Default to enabled since this is a recommended list. + $pm['enabled'] = true; + // Look at the type, if available, to determine if it should be enabled. + if ( isset( $pm['type'] ) ) { + $pm['enabled'] = 'available' !== $pm['type']; + } + } + + return $pm; + }, + $recommended_pms + ); + // Fill in the priority entries with a fallback to the index of the recommendation in the list. + $recommended_pms = array_map( + function ( $pm, $index ) { + if ( ! isset( $pm['priority'] ) ) { + $pm['priority'] = $index; + } else { + $pm['priority'] = intval( $pm['priority'] ); + } + + return $pm; + }, + $recommended_pms, + array_keys( $recommended_pms ) + ); + + return $recommended_pms; + } + /** * Gets the account live mode value. * diff --git a/includes/class-wc-payments-onboarding-service.php b/includes/class-wc-payments-onboarding-service.php index 8700fa4fa29..bce87bcbf0c 100644 --- a/includes/class-wc-payments-onboarding-service.php +++ b/includes/class-wc-payments-onboarding-service.php @@ -150,6 +150,35 @@ function () use ( $locale ) { ); } + /** + * Retrieve and cache the account recommended payment methods list. + * + * @param string $country_code The account's business location country code. Provide a 2-letter ISO country code. + * @param string $locale Optional. The locale to use to i18n the data. + * + * @return ?array The recommended payment methods list. + * NULL on retrieval or validation error. + */ + public function get_recommended_payment_methods( string $country_code, string $locale = '' ): ?array { + $cache_key = Database_Cache::RECOMMENDED_PAYMENT_METHODS . '__' . $country_code; + if ( ! empty( $locale ) ) { + $cache_key .= '__' . $locale; + } + + return \WC_Payments::get_database_cache()->get_or_add( + $cache_key, + function () use ( $country_code, $locale ) { + try { + return $this->payments_api_client->get_recommended_payment_methods( $country_code, $locale ); + } catch ( API_Exception $e ) { + // Return NULL to signal retrieval error. + return null; + } + }, + 'is_array' + ); + } + /** * Retrieve the embedded KYC session and handle initial account creation (if necessary). * diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 01c57eb8969..b3adf5bf7eb 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -81,6 +81,7 @@ class WC_Payments_API_Client implements MultiCurrencyApiClientInterface { const FRAUD_RULESET_API = 'fraud_ruleset'; const COMPATIBILITY_API = 'compatibility'; const REPORTING_API = 'reporting/payment_activity'; + const RECOMMENDED_PAYMENT_METHODS = 'payment_methods/recommended'; /** * Common keys in API requests/responses that we might want to redact. @@ -453,6 +454,65 @@ public function get_transactions_export( $filters = [], $user_email = '', $depos return $this->request( $filters, self::TRANSACTIONS_API . '/download', self::POST ); } + /** + * Fetch account recommended payment methods data for a given country. + * + * @param string $country_code The account's business location country code. Provide a 2-letter ISO country code. + * @param string $locale Optional. The locale to instruct the platform to use for i18n. + * + * @return array The recommended payment methods data. + * @throws API_Exception Exception thrown on request failure. + */ + public function get_recommended_payment_methods( string $country_code, string $locale = '' ): array { + // We can't use the request method here because this route doesn't require a connected store + // and we request this data pre-onboarding. + // By this point, we have an expired transient or the store context has changed. + // Query for incentives by calling the WooPayments API. + $url = add_query_arg( + [ + 'country_code' => $country_code, + 'locale' => $locale, + ], + self::ENDPOINT_BASE . '/' . self::ENDPOINT_REST_BASE . '/' . self::RECOMMENDED_PAYMENT_METHODS, + ); + + $response = wp_remote_get( + $url, + [ + 'headers' => apply_filters( + 'wcpay_api_request_headers', + [ + 'Content-type' => 'application/json; charset=utf-8', + ] + ), + 'user-agent' => $this->user_agent, + 'timeout' => self::API_TIMEOUT_SECONDS, + 'sslverify' => false, + ] + ); + + if ( is_wp_error( $response ) ) { + Logger::error( 'HTTP_REQUEST_ERROR ' . var_export( $response, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + $message = sprintf( + // translators: %1: original error message. + __( 'Http request failed. Reason: %1$s', 'woocommerce-payments' ), + $response->get_error_message() + ); + throw new API_Exception( $message, 'wcpay_http_request_failed', 500 ); + } + + $results = []; + if ( 200 === wp_remote_retrieve_response_code( $response ) ) { + // Decode the results, falling back to an empty array. + $results = $this->extract_response_body( $response ); + if ( ! is_array( $results ) ) { + $results = []; + } + } + + return $results; + } + /** * Fetch a single transaction with provided id. * diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 6da6fbc85c0..1827041a1fc 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -3963,6 +3963,57 @@ public function is_proper_intent_used_with_order_returns_false() { $this->assertFalse( $this->card_gateway->is_proper_intent_used_with_order( WC_Helper_Order::create_order(), 'wrong_intent_id' ) ); } + public function test_get_recommended_payment_method() { + $this->mock_wcpay_account + ->expects( $this->once() ) + ->method( 'get_recommended_payment_methods' ) + ->with( 'US' ); + $this->card_gateway->get_recommended_payment_methods( 'US' ); + } + + public function get_recommended_payment_method_no_country_code_provider() { + return [ + 'provider connected' => [ true, 'test' ], + 'provider not connected' => [ false, 'US' ], + ]; + } + + /** + * @dataProvider get_recommended_payment_method_no_country_code_provider + */ + public function test_get_recommended_payment_method_no_country_code_provided( $is_provider_connected, $country_code ) { + // Set base country fallback to US. + $filter_callback = function () { + return 'US'; + }; + add_filter( 'woocommerce_countries_base_country', $filter_callback ); + + $this->mock_wcpay_account + ->expects( $this->once() ) + ->method( 'is_provider_connected' ) + ->willReturn( $is_provider_connected ); + + $this->mock_wcpay_account + ->expects( $this->any() ) + ->method( 'is_stripe_connected' ) + ->willReturn( true ); + + $this->mock_wcpay_account + ->expects( $this->any() ) + ->method( 'get_account_country' ) + ->willReturn( $country_code ); + + $this->mock_wcpay_account + ->expects( $this->once() ) + ->method( 'get_recommended_payment_methods' ) + ->with( $country_code ); + + $this->assertSame( [], $this->card_gateway->get_recommended_payment_methods( '' ) ); + + // Clean up. + remove_filter( 'woocommerce_countries_base_country', $filter_callback ); + } + /** * Sets up the expectation for a certain factor for the new payment * process to be either set or unset. diff --git a/tests/unit/test-class-wc-payments-account.php b/tests/unit/test-class-wc-payments-account.php index f0087e3e966..934a7a72ada 100644 --- a/tests/unit/test-class-wc-payments-account.php +++ b/tests/unit/test-class-wc-payments-account.php @@ -3174,6 +3174,91 @@ public function test_get_tracking_info() { $this->assertSame( $expected, $this->wcpay_account->get_tracking_info() ); } + public function test_get_recommended_payment_methods_unsupported_country() { + $this->assertSame( [], $this->wcpay_account->get_recommended_payment_methods( 'XZ' ) ); + } + + public function get_recommended_payment_methods_provider() { + return [ + 'No PMs suggested' => [ 'US', [], [] ], + 'Invalid PMs array' => [ + 'US', + [ + 'type' => 'available', + 'enabled' => false, + ], + [], + ], + 'Enabled flag and priority not set' => [ + 'US', + [ + [ + 'id' => 1, + 'title' => 'test PM', + 'type' => 'available', + ], + [ + 'id' => 2, + 'title' => 'test PM 2', + 'type' => 'available', + ], + ], + [ + [ + 'id' => 1, + 'title' => 'test PM', + 'type' => 'available', + 'enabled' => false, + 'priority' => 0, + ], + [ + 'id' => 2, + 'title' => 'test PM 2', + 'type' => 'available', + 'enabled' => false, + 'priority' => 1, + ], + ], + ], + 'Enabled flag and priority set' => [ + 'US', + [ + [ + 'id' => 1, + 'title' => 'test PM', + 'type' => 'available', + 'enabled' => true, + 'priority' => 1, + ], + ], + [ + [ + 'id' => 1, + 'title' => 'test PM', + 'type' => 'available', + 'enabled' => true, + 'priority' => 1, + ], + ], + ], + ]; + } + + /** + * @dataProvider get_recommended_payment_methods_provider + */ + public function test_get_recommended_payment_methods( $country_code, $recommended_pms, $expected ) { + + $this->mock_empty_cache(); + $this->mock_onboarding_service + ->expects( $this->once() ) + ->method( 'get_recommended_payment_methods' ) + ->with( $country_code ) + ->willReturn( $recommended_pms ); + + $this->assertSame( $expected, $this->wcpay_account->get_recommended_payment_methods( $country_code ) ); + } + /** * Sets up the mocked cache to simulate that its empty and call the generator. */ From a842ba4e986e4e141feb7e4d1f8ba53b486fd750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3fer=20Reykjal=C3=ADn?= <13835680+reykjalin@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:54:15 -0500 Subject: [PATCH 05/11] Fix rounding error with deposit products (#9876) --- .../fix-rounding-error-with-deposit-products | 4 ++ includes/multi-currency/MultiCurrency.php | 38 +++++++++++++++++++ .../test-class-multi-currency.php | 17 +++++---- 3 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 changelog/fix-rounding-error-with-deposit-products diff --git a/changelog/fix-rounding-error-with-deposit-products b/changelog/fix-rounding-error-with-deposit-products new file mode 100644 index 00000000000..d42215e3919 --- /dev/null +++ b/changelog/fix-rounding-error-with-deposit-products @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Ceil product prices after applying currency conversion, but before charm pricing and price rounding from settings is applied. diff --git a/includes/multi-currency/MultiCurrency.php b/includes/multi-currency/MultiCurrency.php index 301503d9ca0..9ab1ac0f19a 100644 --- a/includes/multi-currency/MultiCurrency.php +++ b/includes/multi-currency/MultiCurrency.php @@ -832,7 +832,12 @@ public function get_price( $price, string $type ): float { return (float) $price; } + // We must ceil the converted price here so that we don't introduce rounding errors when + // summing up costs. Consider, e.g. a converted price of 10.003 for a 2-decimal currency. + // A single product would cost 10.00, but 2 of them would cost 20.01, _unless_ we round + // the individual parts correctly. $converted_price = ( (float) $price ) * $currency->get_rate(); + $converted_price = $this->ceil_price_for_currency( $converted_price, $currency ); if ( 'tax' === $type || 'coupon' === $type || 'exchange_rate' === $type ) { return $converted_price; @@ -1356,6 +1361,39 @@ protected function ceil_price( float $price, float $rounding ): float { return ceil( $price / $rounding ) * $rounding; } + /** + * Ceils the price to the precision dictated by the number of decimals in the provided currency. + * + * For example: US$10.0091 -> US$10.01, JPY 1001.01 -> JPY 1002. + * + * @param float $price The price to be ceiled. + * @param Currency $currency The currency used to figure out the ceil precision. + * + * @return float The ceiled price. + */ + protected function ceil_price_for_currency( float $price, Currency $currency ): float { + // phpcs:disable Squiz.PHP.CommentedOutCode.Found, example comments look like code. + + // Example to explain the math: + // $price = 10.003. + // expected rounding = 10.01. + + // $num_decimals = 2. + // $factor. = 10^2 = 100. + $num_decimals = absint( + $this->localization_service->get_currency_format( + $currency->get_code() + )['num_decimals'] + ); + $factor = 10 ** $num_decimals; // 10^{$num_decimals}. + + // ceil( 10.003 * $factor ) = ceil( 1_000.3 ) = 1_001. + // 1_001 / 100 = 10.01. + return ceil( $price * $factor ) / $factor; // = 10.01. + + // phpcs:enable Squiz.PHP.CommentedOutCode.Found + } + /** * Sets up the available currencies, which are alphabetical by name. * diff --git a/tests/unit/multi-currency/test-class-multi-currency.php b/tests/unit/multi-currency/test-class-multi-currency.php index 6d4eddaab84..a5a254ed7ec 100644 --- a/tests/unit/multi-currency/test-class-multi-currency.php +++ b/tests/unit/multi-currency/test-class-multi-currency.php @@ -620,24 +620,27 @@ public function test_get_price_returns_converted_coupon_price_without_adjustment WC()->session->set( WCPay\MultiCurrency\MultiCurrency::CURRENCY_SESSION_KEY, 'GBP' ); add_filter( 'wcpay_multi_currency_apply_charm_only_to_products', '__return_false' ); - // 0.708099 * 10 = 7,08099 - $this->assertSame( 7.08099, $this->multi_currency->get_price( '10.0', 'coupon' ) ); + // 0.708099 * 10 = 7.08099. + // ceil( 7.08099, 2 ) = 7.09. + $this->assertSame( 7.09, $this->multi_currency->get_price( '10.0', 'coupon' ) ); } public function test_get_price_returns_converted_exchange_rate_without_adjustments() { WC()->session->set( WCPay\MultiCurrency\MultiCurrency::CURRENCY_SESSION_KEY, 'GBP' ); add_filter( 'wcpay_multi_currency_apply_charm_only_to_products', '__return_false' ); - // 0.708099 * 10 = 7,08099 - $this->assertSame( 7.08099, $this->multi_currency->get_price( '10.0', 'exchange_rate' ) ); + // 0.708099 * 10 = 7.08099. + // ceil( 7.08099, 2 ) = 7.09. + $this->assertSame( 7.09, $this->multi_currency->get_price( '10.0', 'exchange_rate' ) ); } public function test_get_price_returns_converted_tax_price() { WC()->session->set( WCPay\MultiCurrency\MultiCurrency::CURRENCY_SESSION_KEY, 'GBP' ); add_filter( 'wcpay_multi_currency_apply_charm_only_to_products', '__return_false' ); - // 0.708099 * 10 = 7,08099 - $this->assertSame( 7.08099, $this->multi_currency->get_price( '10.0', 'tax' ) ); + // 0.708099 * 10 = 7.08099. + // ceil( 7.08099, 2 ) = 7.09. + $this->assertSame( 7.09, $this->multi_currency->get_price( '10.0', 'tax' ) ); } /** @@ -1014,7 +1017,7 @@ public function test_set_new_customer_currency_meta_does_not_update_user_meta_if public function get_price_provider() { return [ - [ '5.2499', '0.00', 5.2499 ], + [ '5.2499', '0.00', 5.25 ], // Even though the precision is 0.00 we make sure the amount is ceiled to the currency's number of digits. [ '5.2499', '0.25', 5.25 ], [ '5.2500', '0.25', 5.25 ], [ '5.2501', '0.25', 5.50 ], From c241ed64199704a1aa4cbe9612264f09088ef341 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 6 Dec 2024 14:14:29 -0500 Subject: [PATCH 06/11] Normalize HK addresses for Apple Pay (#9869) --- changelog/as-hk-address | 4 + ...lass-express-checkout-hong-kong-states.php | 360 ++++++++++++++++++ ...payments-express-checkout-ajax-handler.php | 4 +- ...ayments-express-checkout-button-helper.php | 40 ++ 4 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 changelog/as-hk-address create mode 100644 includes/constants/class-express-checkout-hong-kong-states.php diff --git a/changelog/as-hk-address b/changelog/as-hk-address new file mode 100644 index 00000000000..d58ddb9ffd9 --- /dev/null +++ b/changelog/as-hk-address @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Normalize HK addresses for ECE diff --git a/includes/constants/class-express-checkout-hong-kong-states.php b/includes/constants/class-express-checkout-hong-kong-states.php new file mode 100644 index 00000000000..cd7154eeca0 --- /dev/null +++ b/includes/constants/class-express-checkout-hong-kong-states.php @@ -0,0 +1,360 @@ +express_checkout_button_helper->normalize_state(); + // 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(); } catch ( Exception $e ) { Logger::error( 'Failed to process express checkout payment: ' . $e ); 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 285ce659d94..18f5de0525e 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 @@ -946,6 +946,46 @@ public function normalize_state() { $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'] ) ) : ''; + // Due to a bug in Apple Pay, the "Region" part of a Hong Kong address is delivered in + // `shipping_postcode`, so we need some special case handling for that. According to + // our sources at Apple Pay people will sometimes use the district or even sub-district + // for this value. As such we check against all regions, districts, and sub-districts + // with both English and Mandarin spelling. + // + // @reykjalin: The check here is quite elaborate in an attempt to make sure this doesn't break once + // Apple Pay fixes the bug that causes address values to be in the wrong place. Because of that the + // algorithm becomes: + // 1. Use the supplied state if it's valid (in case Apple Pay bug is fixed) + // 2. Use the value supplied in the postcode if it's a valid HK region (equivalent to a WC state). + // 3. Fall back to the value supplied in the state. This will likely cause a validation error, in + // which case a merchant can reach out to us so we can either: 1) add whatever the customer used + // as a state to our list of valid states; or 2) let them know the customer must spell the state + // in some way that matches our list of valid states. + // + // @reykjalin: This HK specific sanitazation *should be removed* once Apple Pay fix + // the address bug. More info on that in pc4etw-bY-p2. + if ( 'HK' === $billing_country ) { + include_once WCPAY_ABSPATH . 'includes/constants/class-express-checkout-hong-kong-states.php'; + + if ( ! \WCPay\Constants\Express_Checkout_Hong_Kong_States::is_valid_state( strtolower( $billing_state ) ) ) { + $billing_postcode = ! empty( $_POST['billing_postcode'] ) ? wc_clean( wp_unslash( $_POST['billing_postcode'] ) ) : ''; + if ( \WCPay\Constants\Express_Checkout_Hong_Kong_States::is_valid_state( strtolower( $billing_postcode ) ) ) { + $billing_state = $billing_postcode; + } + } + } + if ( 'HK' === $shipping_country ) { + include_once WCPAY_ABSPATH . 'includes/constants/class-express-checkout-hong-kong-states.php'; + + if ( ! \WCPay\Constants\Express_Checkout_Hong_Kong_States::is_valid_state( strtolower( $shipping_state ) ) ) { + $shipping_postcode = ! empty( $_POST['shipping_postcode'] ) ? wc_clean( wp_unslash( $_POST['shipping_postcode'] ) ) : ''; + if ( \WCPay\Constants\Express_Checkout_Hong_Kong_States::is_valid_state( strtolower( $shipping_postcode ) ) ) { + $shipping_state = $shipping_postcode; + } + } + } + + // Finally we normalize the state value we want to process. if ( $billing_state && $billing_country ) { $_POST['billing_state'] = $this->get_normalized_state( $billing_state, $billing_country ); } From 737aca04b36720ca54acb7377c65e954dba5c0de Mon Sep 17 00:00:00 2001 From: bruce aldridge Date: Mon, 9 Dec 2024 11:20:55 +1300 Subject: [PATCH 07/11] Fix error log messages incorrecty categorized as info (#9890) Co-authored-by: Shendy <73803630+shendy-a8c@users.noreply.github.com> --- changelog/fix-9889-log-level | 4 ++++ includes/class-logger.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-9889-log-level diff --git a/changelog/fix-9889-log-level b/changelog/fix-9889-log-level new file mode 100644 index 00000000000..d2f54e24c1a --- /dev/null +++ b/changelog/fix-9889-log-level @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Errors were incorrectly marked as info in logs. diff --git a/includes/class-logger.php b/includes/class-logger.php index 3384d0fe443..1ce8ae255ab 100644 --- a/includes/class-logger.php +++ b/includes/class-logger.php @@ -36,7 +36,7 @@ class Logger { * 'debug': Debug-level messages. */ public static function log( $message, $level = 'info' ) { - wcpay_get_container()->get( InternalLogger::class )->log( $message ); + wcpay_get_container()->get( InternalLogger::class )->log( $message, $level ); } /** From 15e7ef8c89f87abf07cd6c748a39a98a822ef934 Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Mon, 9 Dec 2024 16:17:48 +0100 Subject: [PATCH 08/11] Add check for `wc_get_payment_gateway_by_order` (#9903) Co-authored-by: Marcin Bot --- changelog/fix-add-payment-method-check | 5 +++++ includes/class-woopay-tracker.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog/fix-add-payment-method-check diff --git a/changelog/fix-add-payment-method-check b/changelog/fix-add-payment-method-check new file mode 100644 index 00000000000..4ffc9e6342f --- /dev/null +++ b/changelog/fix-add-payment-method-check @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Added a check for the gateway id before comparing it + + diff --git a/includes/class-woopay-tracker.php b/includes/class-woopay-tracker.php index 538ec873dc8..fbcdd6bc948 100644 --- a/includes/class-woopay-tracker.php +++ b/includes/class-woopay-tracker.php @@ -543,7 +543,7 @@ public function checkout_order_processed( $order_id ) { $properties = [ 'payment_title' => 'other' ]; // If the order was placed using WooCommerce Payments, record the payment title using Tracks. - if ( strpos( $payment_gateway->id, 'woocommerce_payments' ) === 0 ) { + if ( isset( $payment_gateway->id ) && strpos( $payment_gateway->id, 'woocommerce_payments' ) === 0 ) { $order = wc_get_order( $order_id ); $payment_title = $order->get_payment_method_title(); $properties = [ 'payment_title' => $payment_title ]; From 0ea8272f82e760b0648c072d0307b1bc76582f90 Mon Sep 17 00:00:00 2001 From: Francesco Date: Tue, 10 Dec 2024 14:54:01 +0100 Subject: [PATCH 09/11] fix: console warning for useEffect on plugins page (#9913) --- changelog/fix-use-effect-console-warning | 5 +++++ client/plugins-page/index.js | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelog/fix-use-effect-console-warning diff --git a/changelog/fix-use-effect-console-warning b/changelog/fix-use-effect-console-warning new file mode 100644 index 00000000000..45219e7b39a --- /dev/null +++ b/changelog/fix-use-effect-console-warning @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: fix: console warning on plugins page + + diff --git a/client/plugins-page/index.js b/client/plugins-page/index.js index 24d59e65fa5..b960794f65d 100644 --- a/client/plugins-page/index.js +++ b/client/plugins-page/index.js @@ -77,12 +77,12 @@ const PluginsPage = () => { useEffect( () => { // If the survey is dismissed skip event listeners. if ( isModalDismissed() ) { - return null; + return; } // Abort if the deactivation link is not present. if ( deactivationLink === null ) { - return null; + return; } // Handle click event. From adbac29236168227b9e191a6c7ac4a15a2018d32 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Tue, 10 Dec 2024 18:41:04 +0100 Subject: [PATCH 10/11] Update confirmation modal after progressive onboarding (#9915) --- changelog/update-confirmation-modal-nox | 4 + .../index.tsx | 98 +++++++++---------- .../style.scss | 85 +++++++++++----- .../test/index.test.tsx | 4 +- client/overview/task-list/strings.tsx | 5 +- client/overview/test/index.js | 2 +- 6 files changed, 116 insertions(+), 82 deletions(-) create mode 100644 changelog/update-confirmation-modal-nox diff --git a/changelog/update-confirmation-modal-nox b/changelog/update-confirmation-modal-nox new file mode 100644 index 00000000000..0ffd1af6127 --- /dev/null +++ b/changelog/update-confirmation-modal-nox @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update confirmation modal after onbarding diff --git a/client/overview/modal/progressive-onboarding-eligibility/index.tsx b/client/overview/modal/progressive-onboarding-eligibility/index.tsx index d4b3f79021f..6f6be89a707 100644 --- a/client/overview/modal/progressive-onboarding-eligibility/index.tsx +++ b/client/overview/modal/progressive-onboarding-eligibility/index.tsx @@ -5,8 +5,9 @@ import React, { useEffect, useState } from 'react'; import { __, sprintf } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; import { Button, Modal } from '@wordpress/components'; -import { Icon, store, widget, tool } from '@wordpress/icons'; +import { Icon, store, currencyDollar } from '@wordpress/icons'; import { useDispatch } from '@wordpress/data'; +import interpolateComponents from '@automattic/interpolate-components'; /** * Internal dependencies @@ -59,75 +60,74 @@ const ProgressiveOnboardingEligibilityModal: React.FC = () => { setModalVisible( false ); }; - // Workaround to remove Modal header from the modal until `hideHeader` prop can be used. - useEffect( () => { - document - .querySelector( - '.wcpay-progressive-onboarding-eligibility-modal .components-modal__header-heading-container' - ) - ?.remove(); - }, [] ); - if ( ! modalVisible || modalDismissed ) return null; return ( -

- { __( 'You’re ready to sell.', 'woocommerce-payments' ) } -

- { __( - 'Start selling now and fast track the setup process, or continue the process to set up payouts with WooPayments.', - 'woocommerce-payments' - ) } + { interpolateComponents( { + mixedString: sprintf( + __( + 'Great news — your %s account has been activated. You can now start accepting payments on your store, subject to {{restrictionsLink}}certain restrictions{{/restrictionsLink}}.', + 'woocommerce-payments' + ), + 'WooPayments' + ), + components: { + restrictionsLink: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + }, + } ) }

- -

+ +
+

+ { __( + 'Start selling instantly', + 'woocommerce-payments' + ) } +

{ __( - 'Start selling instantly', + 'You have 30 days from your first transaction or until you reach $5,000 in sales to verify your information and set up payouts.', 'woocommerce-payments' ) } -

- { sprintf( - /* translators: %s: WooPayments */ - __( - '%s enables you to start processing credit card payments right away.', - 'woocommerce-payments' - ), - 'WooPayments' - ) } -
-
- -

- { __( 'Quick and easy setup', 'woocommerce-payments' ) } -

- { __( - 'The setup process is super simple and ensures your store is ready to accept card payments.', - 'woocommerce-payments' - ) } +
- -

- { __( 'Flexible process', 'woocommerce-payments' ) } -

- { __( - 'You have a $5,000 balance limit or 30 days from your first transaction to verify and set up payouts in your account.', - 'woocommerce-payments' - ) } + +
+

+ { __( + 'Start receiving payouts', + 'woocommerce-payments' + ) } +

+ { __( + 'Provide some additional details about your business so you can continue accepting payments and begin receiving payouts without restrictions.', + 'woocommerce-payments' + ) } +