diff --git a/changelog.txt b/changelog.txt index 7d34faaaba..24e51d8cf3 100644 --- a/changelog.txt +++ b/changelog.txt @@ -15,6 +15,7 @@ * Fix - Relax customer validation that was preventing payments from the pay for order page * Fix - Prevent the PMC migration to run when the plugin is not connected to Stripe * Fix - Fixes a fatal error in the OC inbox note when the new checkout is disabled +* Fix - Fix unnecessary Stripe API calls when rendering subscription details = 9.8.0 - 2025-08-11 = * Add - Adds the current setting value for the Optimized Checkout to the Stripe System Status Report data diff --git a/includes/compat/class-wc-stripe-subscriptions-helper.php b/includes/compat/class-wc-stripe-subscriptions-helper.php index a992c89811..4a82e5a4ed 100644 --- a/includes/compat/class-wc-stripe-subscriptions-helper.php +++ b/includes/compat/class-wc-stripe-subscriptions-helper.php @@ -297,4 +297,41 @@ public static function build_subscription_detached_message( $subscription ) { $customer_stripe_page ); } + + /** + * Helper function to get and temporarily cache the payment method details for a customer and payment method ID. + * + * @param string $stripe_customer_id The Stripe customer ID. + * @param string $payment_method_id The Stripe payment method ID. This may be a source ID or a payment method ID. + * @return object|null The payment method details or null if the payment method is not found. + */ + public static function get_subscription_payment_method_details( string $stripe_customer_id, string $payment_method_id ): ?object { + static $cached_payment_methods = []; + + if ( empty( $stripe_customer_id ) || empty( $payment_method_id ) ) { + return null; + } + + if ( isset( $cached_payment_methods[ $stripe_customer_id ][ $payment_method_id ] ) ) { + return $cached_payment_methods[ $stripe_customer_id ][ $payment_method_id ]; + } + + $saved_payment_method = WC_Stripe_API::get_payment_method( $payment_method_id ); + if ( is_wp_error( $saved_payment_method ) ) { + return null; + } + + if ( isset( $saved_payment_method->error ) || empty( $saved_payment_method->id ) || empty( $saved_payment_method->customer ) || $saved_payment_method->customer !== $stripe_customer_id ) { + $saved_payment_method = null; + } + + // Make sure we build the array tree. + if ( ! isset( $cached_payment_methods[ $stripe_customer_id ] ) ) { + $cached_payment_methods[ $stripe_customer_id ] = []; + } + + $cached_payment_methods[ $stripe_customer_id ][ $payment_method_id ] = $saved_payment_method; + + return $saved_payment_method; + } } diff --git a/includes/compat/trait-wc-stripe-subscriptions.php b/includes/compat/trait-wc-stripe-subscriptions.php index 3078684c53..6e5b543743 100644 --- a/includes/compat/trait-wc-stripe-subscriptions.php +++ b/includes/compat/trait-wc-stripe-subscriptions.php @@ -977,7 +977,6 @@ public function maybe_render_subscription_payment_method( $payment_method_to_dis $subscription->save(); } - $stripe_customer = new WC_Stripe_Customer(); $stripe_customer_id = $subscription->get_meta( '_stripe_customer_id', true ); // If we couldn't find a Stripe customer linked to the subscription, fallback to the user meta data. @@ -1011,82 +1010,83 @@ public function maybe_render_subscription_payment_method( $payment_method_to_dis } } - $stripe_customer->set_id( $stripe_customer_id ); - - $payment_method_to_display = __( 'N/A', 'woocommerce-gateway-stripe' ); - try { - // Retrieve all possible payment methods for subscriptions. - foreach ( WC_Stripe_Customer::STRIPE_PAYMENT_METHODS as $payment_method_type ) { - foreach ( $stripe_customer->get_payment_methods( $payment_method_type ) as $source ) { - if ( $source->id !== $stripe_source_id ) { - continue; - } + $saved_payment_method = WC_Stripe_Subscriptions_Helper::get_subscription_payment_method_details( $stripe_customer_id, $stripe_source_id ); - // Legacy handling for Stripe Card objects. ref: https://docs.stripe.com/api/cards/object - if ( isset( $source->object ) && WC_Stripe_Payment_Methods::CARD === $source->object ) { - /* translators: 1) card brand 2) last 4 digits */ - $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->brand ) ? wc_get_credit_card_type_label( $source->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->last4 ); - break 2; - } - - switch ( $source->type ) { - case WC_Stripe_Payment_Methods::CARD: - /* translators: 1) card brand 2) last 4 digits */ - $payment_method_to_display = sprintf( __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), ( isset( $source->card->brand ) ? wc_get_credit_card_type_label( $source->card->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), $source->card->last4 ); - break 3; - case WC_Stripe_Payment_Methods::SEPA_DEBIT: - /* translators: 1) last 4 digits of SEPA Direct Debit */ - $payment_method_to_display = sprintf( __( 'Via SEPA Direct Debit ending in %1$s', 'woocommerce-gateway-stripe' ), $source->sepa_debit->last4 ); - break 3; - case WC_Stripe_Payment_Methods::CASHAPP_PAY: - /* translators: 1) Cash App Cashtag */ - $payment_method_to_display = sprintf( __( 'Via Cash App Pay (%1$s)', 'woocommerce-gateway-stripe' ), $source->cashapp->cashtag ); - break 3; - case WC_Stripe_Payment_Methods::LINK: - /* translators: 1) email address associated with the Stripe Link payment method */ - $payment_method_to_display = sprintf( __( 'Via Stripe Link (%1$s)', 'woocommerce-gateway-stripe' ), $source->link->email ); - break 3; - case WC_Stripe_Payment_Methods::ACH: - $payment_method_to_display = sprintf( - /* translators: 1) account type (checking, savings), 2) last 4 digits of account. */ - __( 'Via %1$s Account ending in %2$s', 'woocommerce-gateway-stripe' ), - ucfirst( $source->us_bank_account->account_type ), - $source->us_bank_account->last4 - ); - break 3; - case WC_Stripe_Payment_Methods::BECS_DEBIT: - $payment_method_to_display = sprintf( - /* translators: last 4 digits of account. */ - __( 'BECS Direct Debit ending in %s', 'woocommerce-gateway-stripe' ), - $source->au_becs_debit->last4 - ); - break 3; - case WC_Stripe_Payment_Methods::ACSS_DEBIT: - $payment_method_to_display = sprintf( - /* translators: 1) bank name, 2) last 4 digits of account. */ - __( 'Via %1$s ending in %2$s', 'woocommerce-gateway-stripe' ), - $source->acss_debit->bank_name, - $source->acss_debit->last4 - ); - break 3; - case WC_Stripe_Payment_Methods::BACS_DEBIT: - /* translators: 1) the Bacs Direct Debit payment method's last 4 numbers */ - $payment_method_to_display = sprintf( __( 'Via Bacs Direct Debit ending in (%1$s)', 'woocommerce-gateway-stripe' ), $source->bacs_debit->last4 ); - break 3; - case WC_Stripe_Payment_Methods::AMAZON_PAY: - /* translators: 1) the Amazon Pay payment method's email */ - $payment_method_to_display = sprintf( __( 'Via Amazon Pay (%1$s)', 'woocommerce-gateway-stripe' ), $source->billing_details->email ?? '' ); - break 3; - } - } + if ( null !== $saved_payment_method ) { + return $this->get_payment_method_to_display_for_payment_method( $saved_payment_method ); } } catch ( WC_Stripe_Exception $e ) { wc_add_notice( $e->getLocalizedMessage(), 'error' ); WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); } - return $payment_method_to_display; + return __( 'N/A', 'woocommerce-gateway-stripe' ); + } + + /** + * Helper function to get the descriptive text for a payment method or source. + * + * @param object $payment_method The payment method or source object. + * @return string The descriptive text for the payment method or source. + */ + protected function get_payment_method_to_display_for_payment_method( object $payment_method ): string { + // Legacy handling for Stripe Card objects. ref: https://docs.stripe.com/api/cards/object + if ( isset( $payment_method->object ) && WC_Stripe_Payment_Methods::CARD === $payment_method->object ) { + return sprintf( + /* translators: 1) card brand 2) last 4 digits */ + __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), + ( isset( $payment_method->brand ) ? wc_get_credit_card_type_label( $payment_method->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), + $payment_method->last4 + ); + } + + switch ( $payment_method->type ) { + case WC_Stripe_Payment_Methods::CARD: + return sprintf( + /* translators: 1) card brand 2) last 4 digits */ + __( 'Via %1$s card ending in %2$s', 'woocommerce-gateway-stripe' ), + ( isset( $payment_method->card->brand ) ? wc_get_credit_card_type_label( $payment_method->card->brand ) : __( 'N/A', 'woocommerce-gateway-stripe' ) ), + $payment_method->card->last4 + ); + case WC_Stripe_Payment_Methods::SEPA_DEBIT: + /* translators: 1) last 4 digits of SEPA Direct Debit */ + return sprintf( __( 'Via SEPA Direct Debit ending in %1$s', 'woocommerce-gateway-stripe' ), $payment_method->sepa_debit->last4 ); + case WC_Stripe_Payment_Methods::CASHAPP_PAY: + /* translators: 1) Cash App Cashtag */ + return sprintf( __( 'Via Cash App Pay (%1$s)', 'woocommerce-gateway-stripe' ), $payment_method->cashapp->cashtag ); + case WC_Stripe_Payment_Methods::LINK: + /* translators: 1) email address associated with the Stripe Link payment method */ + return sprintf( __( 'Via Stripe Link (%1$s)', 'woocommerce-gateway-stripe' ), $payment_method->link->email ); + case WC_Stripe_Payment_Methods::ACH: + return sprintf( + /* translators: 1) account type (checking, savings), 2) last 4 digits of account. */ + __( 'Via %1$s Account ending in %2$s', 'woocommerce-gateway-stripe' ), + ucfirst( $payment_method->us_bank_account->account_type ), + $payment_method->us_bank_account->last4 + ); + case WC_Stripe_Payment_Methods::BECS_DEBIT: + return sprintf( + /* translators: last 4 digits of account. */ + __( 'BECS Direct Debit ending in %s', 'woocommerce-gateway-stripe' ), + $payment_method->au_becs_debit->last4 + ); + case WC_Stripe_Payment_Methods::ACSS_DEBIT: + return sprintf( + /* translators: 1) bank name, 2) last 4 digits of account. */ + __( 'Via %1$s ending in %2$s', 'woocommerce-gateway-stripe' ), + $payment_method->acss_debit->bank_name, + $payment_method->acss_debit->last4 + ); + case WC_Stripe_Payment_Methods::BACS_DEBIT: + /* translators: 1) the Bacs Direct Debit payment method's last 4 numbers */ + return sprintf( __( 'Via Bacs Direct Debit ending in (%1$s)', 'woocommerce-gateway-stripe' ), $payment_method->bacs_debit->last4 ); + case WC_Stripe_Payment_Methods::AMAZON_PAY: + /* translators: 1) the Amazon Pay payment method's email */ + return sprintf( __( 'Via Amazon Pay (%1$s)', 'woocommerce-gateway-stripe' ), $payment_method->billing_details->email ?? '' ); + } + + return __( 'N/A', 'woocommerce-gateway-stripe' ); } /** diff --git a/readme.txt b/readme.txt index d7b36164f1..42b47de961 100644 --- a/readme.txt +++ b/readme.txt @@ -119,5 +119,6 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o * Fix - Ensure all Javascript strings use the correct text domain for translation * Tweak - Use more specific selector in express checkout e2e tests * Tweak - Small improvements to e2e tests +* Fix - Fix unnecessary Stripe API calls when rendering subscription details [See changelog for full details across versions](https://raw.githubusercontent.com/woocommerce/woocommerce-gateway-stripe/trunk/changelog.txt). diff --git a/tests/phpunit/WC_Stripe_Payment_Gateway_Test.php b/tests/phpunit/WC_Stripe_Payment_Gateway_Test.php index 5ff56f4b6c..1d77e80c3b 100644 --- a/tests/phpunit/WC_Stripe_Payment_Gateway_Test.php +++ b/tests/phpunit/WC_Stripe_Payment_Gateway_Test.php @@ -581,61 +581,192 @@ public function test_get_balance_transaction_id_from_charge() { $this->assertEquals( null, $this->gateway->get_balance_transaction_id_from_charge( null ) ); } + public function provide_test_render_subscription_payment_method_cases(): array { + return [ + 'VISA card ending in 4242' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'visa', + 'last4' => '4242', + ], + 'expected_result' => 'Via Visa card ending in 4242', + ], + 'MasterCard ending in 1234' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'mastercard', + 'last4' => '1234', + ], + 'expected_result' => 'Via MasterCard card ending in 1234', + ], + 'American Express card ending in 5678' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'amex', + 'last4' => '5678', + ], + 'expected_result' => 'Via Amex card ending in 5678', + ], + 'JCB card ending in 9012' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'jcb', + 'last4' => '9012', + ], + 'expected_result' => 'Via JCB card ending in 9012', + ], + 'Unknown card type ending in 0000' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'dummy', + 'last4' => '0000', + ], + 'expected_result' => 'Via Dummy card ending in 0000', + ], + 'SEPA Debit ending in 1234' => [ + 'payment_method_type' => 'sepa_debit', + 'payment_method_fields' => [ + 'last4' => '1234', + ], + 'expected_result' => 'Via SEPA Direct Debit ending in 1234', + ], + 'Cash App Pay with cashtag TEST321' => [ + 'payment_method_type' => 'cashapp', + 'payment_method_fields' => [ + 'cashtag' => 'TEST321', + ], + 'expected_result' => 'Via Cash App Pay (TEST321)', + ], + 'Stripe Link with email test@example.com' => [ + 'payment_method_type' => 'link', + 'payment_method_fields' => [ + 'email' => 'test@example.com', + ], + 'expected_result' => 'Via Stripe Link (test@example.com)', + ], + 'ACH checking ending in 1357' => [ + 'payment_method_type' => 'us_bank_account', + 'payment_method_fields' => [ + 'account_type' => 'checking', + 'last4' => '1357', + ], + 'expected_result' => 'Via Checking Account ending in 1357', + ], + 'ACH savings ending in 2468' => [ + 'payment_method_type' => 'us_bank_account', + 'payment_method_fields' => [ + 'account_type' => 'savings', + 'last4' => '2468', + ], + 'expected_result' => 'Via Savings Account ending in 2468', + ], + 'BECS Debit ending in 3579' => [ + 'payment_method_type' => 'au_becs_debit', + 'payment_method_fields' => [ + 'last4' => '3579', + ], + 'expected_result' => 'BECS Direct Debit ending in 3579', + ], + 'ACSS Debit ending in 4680' => [ + 'payment_method_type' => 'acss_debit', + 'payment_method_fields' => [ + 'bank_name' => 'Test Bank', + 'last4' => '4680', + ], + 'expected_result' => 'Via Test Bank ending in 4680', + ], + 'BACS Debit ending in 5791' => [ + 'payment_method_type' => 'bacs_debit', + 'payment_method_fields' => [ + 'last4' => '5791', + ], + 'expected_result' => 'Via Bacs Direct Debit ending in (5791)', + ], + 'Amazon Pay with email test@example.com' => [ + 'payment_method_type' => 'amazon_pay', + 'payment_method_fields' => [], + 'expected_result' => 'Via Amazon Pay (test@example.com)', + 'additional_fields' => [ + 'billing_details' => [ + 'email' => 'test@example.com', + ], + ], + ], + 'Unknown payment method' => [ + 'payment_method_type' => 'unknown', + 'payment_method_fields' => [], + 'expected_result' => 'N/A', + ], + 'Payment method with customer mismatch' => [ + 'payment_method_type' => 'card', + 'payment_method_fields' => [ + 'brand' => 'visa', + 'last4' => '9753', + ], + 'expected_result' => 'N/A', + 'additional_fields' => [ + 'customer' => 'cus_other', + ], + ], + ]; + } + /** * Tests for Card brand and last 4 digits are displayed correctly for subscription. * * @see WC_Stripe_Subscriptions_Trait::maybe_render_subscription_payment_method() + * @dataProvider provide_test_render_subscription_payment_method_cases */ - public function test_render_subscription_payment_method() { + public function test_render_subscription_payment_method( string $payment_method_type, array $payment_method_fields, string $expected_result, ?array $additional_fields = null ) { $mock_subscription = WC_Helper_Order::create_order(); // We can use an order as a subscription. $mock_subscription->set_payment_method( 'stripe' ); - $mock_subscription->update_meta_data( '_stripe_source_id', 'src_mock' ); - $mock_subscription->update_meta_data( '_stripe_customer_id', 'cus_mock' ); - $mock_subscription->save(); + static $mock_payment_method_id_counter = 0; + $mock_payment_method_id_counter++; - // This is the key the customer's payment methods are stored under in the transient. - $transient_key = WC_Stripe_Customer::PAYMENT_METHODS_TRANSIENT_KEY . 'cardcus_mock'; + $id_suffix = isset( $payment_method_fields['last4'] ) ? $payment_method_fields['last4'] : (string) $mock_payment_method_id_counter; + $mock_payment_method_id = 'pm_mock' . $payment_method_type . '_' . $id_suffix; - $mock_payment_method = new stdClass(); - $mock_payment_method->id = 'src_mock'; - $mock_payment_method->type = 'card'; - $mock_payment_method->card = new stdClass(); - - // VISA ending in 4242 - $mock_payment_method->card->brand = 'visa'; - $mock_payment_method->card->last4 = '4242'; - - set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); - $this->assertEquals( 'Via Visa card ending in 4242', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + $mock_subscription->update_meta_data( '_stripe_source_id', $mock_payment_method_id ); + $mock_subscription->update_meta_data( '_stripe_customer_id', 'cus_mock' ); + $mock_subscription->save(); - // MasterCard ending in 1234 - $mock_payment_method->card->brand = 'mastercard'; - $mock_payment_method->card->last4 = '1234'; + $mock_payment_method_data = [ + 'id' => $mock_payment_method_id, + 'type' => $payment_method_type, + 'customer' => 'cus_mock', + ]; + $mock_payment_method_data[ $payment_method_type ] = $payment_method_fields; - set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); - $this->assertEquals( 'Via MasterCard card ending in 1234', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + if ( is_array( $additional_fields ) ) { + $mock_payment_method_data = array_merge( $mock_payment_method_data, $additional_fields ); + } - // American Express ending in 5678 - $mock_payment_method->card->brand = 'amex'; - $mock_payment_method->card->last4 = '5678'; + $expected_url = '/v1/payment_methods/' . $mock_payment_method_id; - set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); - $this->assertEquals( 'Via Amex card ending in 5678', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + // Mock the Stripe API payment method response + $mock_payment_method_api = function ( $preempt, $request_args, $url ) use ( $expected_url, $mock_payment_method_data ) { + if ( str_ends_with( $url, $expected_url ) ) { + $response = [ + 'headers' => [], + 'body' => wp_json_encode( $mock_payment_method_data ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + return $response; + } + return $preempt; + }; - // JCB ending in 9012' - $mock_payment_method->card->brand = 'jcb'; - $mock_payment_method->card->last4 = '9012'; + add_filter( 'pre_http_request', $mock_payment_method_api, 10, 3 ); - set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); + $result = $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ); - // Unknown card type - $mock_payment_method->card->brand = 'dummy'; - $mock_payment_method->card->last4 = '0000'; + remove_filter( 'pre_http_request', $mock_payment_method_api ); - set_transient( $transient_key, [ $mock_payment_method ], DAY_IN_SECONDS ); - // Card brands that WC core doesn't recognize will be displayed as ucwords. - $this->assertEquals( 'Via Dummy card ending in 0000', $this->gateway->maybe_render_subscription_payment_method( 'N/A', $mock_subscription ) ); + $this->assertEquals( $expected_result, $result ); } /**