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] 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. */