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 );
+ }
}