Commit e1c569b6111 for woocommerce

commit e1c569b6111de583f9465e6b2d934dc421f3279e
Author: Alefe Souza <contact@alefesouza.com>
Date:   Wed Sep 16 21:27:08 2026 -0300

    Fix pay page ignoring the order's assigned payment method (#68751)

diff --git a/plugins/woocommerce/changelog/31339-preselect-assigned-gateway-on-pay-page b/plugins/woocommerce/changelog/31339-preselect-assigned-gateway-on-pay-page
new file mode 100644
index 00000000000..da5fd025769
--- /dev/null
+++ b/plugins/woocommerce/changelog/31339-preselect-assigned-gateway-on-pay-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Pre-select the payment method a merchant assigned to an admin-created order on its Pay for order page, instead of the first enabled method.
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
index 7e9b6be4b7f..1514c1576f8 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
@@ -91,7 +91,7 @@ class WC_Shortcode_Checkout {
 				$order     = wc_get_order( $order_id );

 				// Order or payment link is invalid.
-				if ( ! $order || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
+				if ( ! $order instanceof WC_Order || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
 					throw new Exception( __( 'Sorry, this order is invalid and cannot be paid for.', 'woocommerce' ) );
 				}

@@ -195,8 +195,20 @@ class WC_Shortcode_Checkout {
 				);
 				WC()->customer->save();

-				$available_gateways = WC()->payment_gateways()->get_available_payment_gateways();
-				WC()->payment_gateways()->set_current_gateway( $available_gateways );
+				$available_gateways  = WC()->payment_gateways()->get_available_payment_gateways();
+				$preselected_gateway = self::get_order_preselected_gateway( $order, $available_gateways );
+
+				// The method a merchant set on an admin-created order is the default selection; the shopper can still pick another.
+				if ( $preselected_gateway ) {
+					// Third party code may have marked another gateway as current, which would leave two of them expanded on the form.
+					foreach ( $available_gateways as $gateway ) {
+						$gateway->chosen = false;
+					}
+
+					$preselected_gateway->set_current();
+				} else {
+					WC()->payment_gateways()->set_current_gateway( $available_gateways );
+				}

 				/**
 				 * Allows the text of the submit button on the Pay for Order page to be changed.
@@ -256,6 +268,27 @@ class WC_Shortcode_Checkout {
 		do_action( 'after_woocommerce_pay' );
 	}

+	/**
+	 * Get the gateway to pre-select on the pay page, when it should differ from the default selection.
+	 *
+	 * A merchant assigns the method when creating an order in admin, so that assignment is the pre-selection.
+	 * Orders placed through checkout carry the method the shopper picked, so those keep the default selection.
+	 *
+	 * @since 11.3.0
+	 * @param WC_Order             $order              Order being paid for.
+	 * @param WC_Payment_Gateway[] $available_gateways Gateways available on the pay page, keyed by gateway ID.
+	 * @return WC_Payment_Gateway|null
+	 */
+	private static function get_order_preselected_gateway( WC_Order $order, array $available_gateways ) {
+		if ( ! $order->is_created_via( 'admin' ) ) {
+			return null;
+		}
+
+		$payment_method = $order->get_payment_method();
+
+		return $payment_method && isset( $available_gateways[ $payment_method ] ) ? $available_gateways[ $payment_method ] : null;
+	}
+
 	/**
 	 * Show the thanks page.
 	 *
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 7879bfce6d2..f811f723455 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -31000,52 +31000,16 @@ parameters:
 			count: 1
 			path: includes/shortcodes/class-wc-shortcode-cart.php

-		-
-			message: '#^@param WC_Order \$order does not accept actual type of parameter\: WC_Order\|WC_Order_Refund\.$#'
-			identifier: parameter.phpDocType
-			count: 1
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_country\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_email\(\)\.$#'
-			identifier: method.notFound
-			count: 1
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_postcode\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_billing_state\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
-		-
-			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_checkout_payment_url\(\)\.$#'
-			identifier: method.notFound
-			count: 2
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
 		-
 			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:get_order_key\(\)\.$#'
 			identifier: method.notFound
-			count: 2
+			count: 1
 			path: includes/shortcodes/class-wc-shortcode-checkout.php

 		-
 			message: '#^Call to an undefined method WC_Order\|WC_Order_Refund\:\:needs_payment\(\)\.$#'
 			identifier: method.notFound
-			count: 2
+			count: 1
 			path: includes/shortcodes/class-wc-shortcode-checkout.php

 		-
@@ -31090,12 +31054,6 @@ parameters:
 			count: 1
 			path: includes/shortcodes/class-wc-shortcode-checkout.php

-		-
-			message: '#^Parameter \#1 \$order of static method WC_Shortcode_Checkout\:\:guest_should_verify_email\(\) expects WC_Order, WC_Order\|WC_Order_Refund given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/shortcodes/class-wc-shortcode-checkout.php
-
 		-
 			message: '#^Parameter \#2 \$user_string of function hash_equals expects string, array\|string given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
index 52148ebce74..409da421f6d 100644
--- a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
@@ -13,7 +13,41 @@ declare( strict_types = 1 );
 class WC_Shortcode_Checkout_Test extends WC_Unit_Test_Case {

 	/**
-	 * Restore the request and query state touched by this test.
+	 * Gateway `enabled` values before the test switched them, keyed by gateway ID.
+	 *
+	 * @var array<string, string>
+	 */
+	private $original_gateway_enabled = array();
+
+	/**
+	 * Billing props of `WC()->customer` before the test, since the pay page copies the order's address onto it.
+	 *
+	 * @var array<string, string>
+	 */
+	private $original_customer_billing = array();
+
+	/**
+	 * Callback currently reordering the available gateways, if any.
+	 *
+	 * @var callable|null
+	 */
+	private $gateway_order_filter;
+
+	/**
+	 * Remember the shared customer state this test may change.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->original_customer_billing = array(
+			'billing_country'  => WC()->customer->get_billing_country(),
+			'billing_state'    => WC()->customer->get_billing_state(),
+			'billing_postcode' => WC()->customer->get_billing_postcode(),
+		);
+	}
+
+	/**
+	 * Restore the request, query, session, customer and gateway state touched by this test.
 	 */
 	public function tearDown(): void {
 		global $wp;
@@ -23,9 +57,110 @@ class WC_Shortcode_Checkout_Test extends WC_Unit_Test_Case {
 		unset( $wp->query_vars['order-received'] );
 		unset( $wp->query_vars['order-pay'] );

+		WC()->customer->set_props( $this->original_customer_billing );
+		WC()->customer->save();
+
+		foreach ( $this->original_gateway_enabled as $gateway_id => $enabled ) {
+			WC()->payment_gateways()->payment_gateways()[ $gateway_id ]->enabled = $enabled;
+		}
+		$this->original_gateway_enabled = array();
+
+		if ( $this->gateway_order_filter ) {
+			remove_filter( 'woocommerce_available_payment_gateways', $this->gateway_order_filter );
+			$this->gateway_order_filter = null;
+		}
+
+		// The gateway objects are shared singletons, so clear the selection on all of them, not only those this test enabled.
+		foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) {
+			$gateway->chosen = false;
+		}
+
+		if ( isset( WC()->session ) ) {
+			WC()->session->set( 'chosen_payment_method', null );
+		}
+
 		parent::tearDown();
 	}

+	/**
+	 * Enable the given gateways for the test, remembering their previous state.
+	 *
+	 * @param string[] $gateway_ids Gateway IDs to enable.
+	 */
+	private function enable_gateways( array $gateway_ids ): void {
+		foreach ( $gateway_ids as $gateway_id ) {
+			$gateway                                       = WC()->payment_gateways()->payment_gateways()[ $gateway_id ];
+			$this->original_gateway_enabled[ $gateway_id ] = $gateway->enabled;
+			$gateway->enabled                              = 'yes';
+		}
+	}
+
+	/**
+	 * Move a gateway to the front of the available list, so the default selection would land on it.
+	 *
+	 * @param string $gateway_id Gateway ID to list first.
+	 */
+	private function put_gateway_first( string $gateway_id ): void {
+		$this->gateway_order_filter = function ( $gateways ) use ( $gateway_id ) {
+			return isset( $gateways[ $gateway_id ] ) ? array( $gateway_id => $gateways[ $gateway_id ] ) + $gateways : $gateways;
+		};
+
+		add_filter( 'woocommerce_available_payment_gateways', $this->gateway_order_filter );
+	}
+
+	/**
+	 * Render the pay page for an order, as the shopper following its payment link.
+	 *
+	 * @param WC_Order $order Order to pay for.
+	 * @return string Rendered markup.
+	 */
+	private function render_pay_page( WC_Order $order ): string {
+		global $wp;
+
+		$wp->query_vars['order-pay'] = $order->get_id();
+		$_GET['pay_for_order']       = 'true';
+		$_GET['key']                 = $order->get_order_key();
+
+		ob_start();
+		WC_Shortcode_Checkout::output( array() );
+
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Create a pending guest order with the given origin and payment method.
+	 *
+	 * @param string $created_via    Value for the order's `created_via` prop.
+	 * @param string $payment_method Gateway ID to assign.
+	 * @return WC_Order
+	 */
+	private function create_pending_order( string $created_via, string $payment_method ): WC_Order {
+		$order = WC_Helper_Order::create_order( 0 );
+		$order->set_created_via( $created_via );
+		$order->set_payment_method( $payment_method );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Get the IDs of the payment method radios that are checked on rendered pay page markup.
+	 *
+	 * @param string $output Rendered pay page.
+	 * @return string[]
+	 */
+	private function get_checked_gateways( string $output ): array {
+		preg_match_all( '/id="payment_method_([^"]+)"[^>]*\/>/', $output, $radios, PREG_SET_ORDER );
+		$checked = array();
+		foreach ( $radios as $radio ) {
+			if ( false !== strpos( $radio[0], 'checked=' ) ) {
+				$checked[] = $radio[1];
+			}
+		}
+
+		return $checked;
+	}
+
 	/**
 	 * An array `key` must be treated as absent rather than reaching hash_equals().
 	 */
@@ -63,4 +198,75 @@ class WC_Shortcode_Checkout_Test extends WC_Unit_Test_Case {

 		$this->assertStringContainsString( 'Sorry, this order is invalid and cannot be paid for.', $output );
 	}
+
+	/**
+	 * @testdox The pay page should pre-select the method a merchant assigned to an admin-created order, keeping the others available.
+	 */
+	public function test_order_pay_preselects_merchant_assigned_gateway_for_admin_orders(): void {
+		$this->enable_gateways( array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID ) );
+		$order = $this->create_pending_order( 'admin', WC_Gateway_Cheque::ID );
+
+		$output = $this->render_pay_page( $order );
+
+		$this->assertSame( array( WC_Gateway_Cheque::ID ), $this->get_checked_gateways( $output ) );
+		$this->assertStringContainsString( 'id="payment_method_' . WC_Gateway_BACS::ID . '"', $output, 'Other enabled gateways should still be offered.' );
+	}
+
+	/**
+	 * @testdox The pay page should prefer the merchant-assigned method over the one chosen in the shopper's session.
+	 */
+	public function test_order_pay_prefers_merchant_assigned_gateway_over_session_choice(): void {
+		$this->enable_gateways( array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID ) );
+		$order = $this->create_pending_order( 'admin', WC_Gateway_Cheque::ID );
+
+		// List the assigned gateway first and pick a different one in the session, so only the session choice could steal the selection.
+		$this->put_gateway_first( WC_Gateway_Cheque::ID );
+		WC()->session->set( 'chosen_payment_method', WC_Gateway_BACS::ID );
+
+		$output = $this->render_pay_page( $order );
+
+		$this->assertSame( array( WC_Gateway_Cheque::ID ), $this->get_checked_gateways( $output ) );
+	}
+
+	/**
+	 * @testdox The pay page should clear a selection made by third-party code, so only the merchant-assigned method is expanded.
+	 */
+	public function test_order_pay_clears_gateway_chosen_by_third_party_code(): void {
+		$this->enable_gateways( array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID ) );
+		$order = $this->create_pending_order( 'admin', WC_Gateway_Cheque::ID );
+		WC()->payment_gateways()->payment_gateways()[ WC_Gateway_BACS::ID ]->set_current();
+
+		$output = $this->render_pay_page( $order );
+
+		$this->assertSame( array( WC_Gateway_Cheque::ID ), $this->get_checked_gateways( $output ) );
+	}
+
+	/**
+	 * @testdox The pay page should keep the default selection when the order was not created in admin.
+	 */
+	public function test_order_pay_keeps_default_gateway_for_checkout_orders(): void {
+		$this->enable_gateways( array( WC_Gateway_BACS::ID, WC_Gateway_Cheque::ID ) );
+		$order = $this->create_pending_order( 'checkout', WC_Gateway_Cheque::ID );
+
+		$output  = $this->render_pay_page( $order );
+		$checked = $this->get_checked_gateways( $output );
+
+		$this->assertCount( 1, $checked, 'Exactly one gateway should be pre-selected on the pay page.' );
+		$this->assertNotContains( WC_Gateway_Cheque::ID, $checked, 'The assigned method should not override the default for orders the shopper placed.' );
+	}
+
+	/**
+	 * @testdox The pay page should fall back to the default selection when the assigned method is no longer available.
+	 */
+	public function test_order_pay_falls_back_when_assigned_gateway_is_unavailable(): void {
+		$this->enable_gateways( array( WC_Gateway_BACS::ID, WC_Gateway_COD::ID ) );
+		$order = $this->create_pending_order( 'admin', WC_Gateway_Cheque::ID );
+
+		$output  = $this->render_pay_page( $order );
+		$checked = $this->get_checked_gateways( $output );
+
+		$this->assertCount( 1, $checked, 'Exactly one gateway should be pre-selected on the pay page.' );
+		$this->assertNotContains( WC_Gateway_Cheque::ID, $checked );
+		$this->assertStringNotContainsString( 'id="payment_method_' . WC_Gateway_Cheque::ID . '"', $output );
+	}
 }