Commit e3434539969 for woocommerce

commit e34345399696298351deb3167cbf084624f32dc3
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Fri Oct 16 13:01:45 2026 +0300

    Add the payment method to the Analytics Orders report (#68970)

    * Add the payment method to the Analytics Orders report

    * Fix untitled gateway in Orders export and type payment_method

    The server CSV left the payment method cell empty for an installed gateway without a title, while the table showed the gateway id. The export now falls back to the id too.

    The OrderReport type in @woocommerce/data gains the payment_method field that the reports/orders endpoint returns.

    * Add changelog entry for the OrderReport payment_method field

    * Log a failed batch read in the payment method backfill

    Also use the imported class names and the existing cache invalidation helper.

    * Simplify the payment method backfill and move the gateway setting

    * Limit the parent payment method backfill to refunds

    A child order that is not a refund has no gateway of its own, and the sync stores NULL for it. The backfill gave it the gateway of its parent.

    * Address review feedback on the Orders report payment method

    Keep the stats row of an order synced before dbDelta adds the column, move the column to the end of the table and the CSV export, drop the index until the filter needs it, and start the batch test past leftover rows.

    * Invalidate the report cache directly in the payment method backfill

diff --git a/packages/js/data/changelog/47283-analytics-orders-payment-method b/packages/js/data/changelog/47283-analytics-orders-payment-method
new file mode 100644
index 00000000000..72f55dda1ac
--- /dev/null
+++ b/packages/js/data/changelog/47283-analytics-orders-payment-method
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the payment_method field to the OrderReport type.
diff --git a/packages/js/data/src/reports/types.ts b/packages/js/data/src/reports/types.ts
index 9903c1942b1..38e2a1642db 100644
--- a/packages/js/data/src/reports/types.ts
+++ b/packages/js/data/src/reports/types.ts
@@ -154,6 +154,8 @@ export type OrderReport = {
 	total_formatted: string;
 	/** Returning or new customer. */
 	customer_type: string;
+	/** Payment gateway ID the order was paid with. */
+	payment_method: string;
 	extended_info: {
 		/** List of order product IDs, names, quantities. */
 		products: Array< {
diff --git a/plugins/woocommerce/changelog/47283-analytics-orders-payment-method b/plugins/woocommerce/changelog/47283-analytics-orders-payment-method
new file mode 100644
index 00000000000..a7cf6f81cb1
--- /dev/null
+++ b/plugins/woocommerce/changelog/47283-analytics-orders-payment-method
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Analytics: show the payment method on the Orders report, as a table column and a CSV export column.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/orders/table.js b/plugins/woocommerce/client/admin/client/analytics/report/orders/table.js
index 87f4971dccb..815e0c9f77d 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/orders/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/orders/table.js
@@ -14,11 +14,15 @@ import { CurrencyContext } from '@woocommerce/currency';
  * Internal dependencies
  */
 import ReportTable from '../../components/report-table';
-import { getAdminSetting } from '~/utils/admin-settings';
+import { getAdminSetting, PAYMENT_GATEWAYS } from '~/utils/admin-settings';

 const capitalizeFirstLetter = ( expr ) =>
 	expr.charAt( 0 ).toUpperCase() + expr.slice( 1 );

+// Gateways that are no longer installed keep the id stored on the order.
+const getPaymentMethodTitle = ( paymentMethod ) =>
+	PAYMENT_GATEWAYS[ paymentMethod ] || paymentMethod || '';
+
 class OrdersReportTable extends Component {
 	constructor() {
 		super();
@@ -98,6 +102,12 @@ class OrdersReportTable extends Component {
 				required: false,
 				isSortable: false,
 			},
+			{
+				label: __( 'Payment method', 'woocommerce' ),
+				key: 'payment_method',
+				required: false,
+				isSortable: false,
+			},
 		];
 	}

@@ -131,6 +141,7 @@ class OrdersReportTable extends Component {
 				parent_id: parentId,
 				status,
 				customer_type: customerType,
+				payment_method: paymentMethod,
 			} = row;
 			const extendedInfo = row.extended_info || {};
 			const { coupons, customer, products } = extendedInfo;
@@ -252,6 +263,10 @@ class OrdersReportTable extends Component {
 					display: extendedInfo.attribution.origin,
 					value: extendedInfo.attribution.origin,
 				},
+				{
+					display: getPaymentMethodTitle( paymentMethod ),
+					value: getPaymentMethodTitle( paymentMethod ),
+				},
 			];
 		} );
 	}
diff --git a/plugins/woocommerce/client/admin/client/utils/admin-settings.js b/plugins/woocommerce/client/admin/client/utils/admin-settings.js
index e1349d94b87..a49932cb047 100644
--- a/plugins/woocommerce/client/admin/client/utils/admin-settings.js
+++ b/plugins/woocommerce/client/admin/client/utils/admin-settings.js
@@ -88,6 +88,7 @@ export const LOCALE = getSetting( 'locale' );
 export const SITE_TITLE = getSetting( 'siteTitle' );
 export const WC_ASSET_URL = getSetting( 'wcAssetUrl' );
 export const ORDER_STATUSES = getAdminSetting( 'orderStatuses' );
+export const PAYMENT_GATEWAYS = getAdminSetting( 'paymentGateways', {} );

 /**
  * Sets a value to a property on the settings state.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index bb781806ff2..17ccf82509c 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -370,6 +370,7 @@ class WC_Install {
 			'wc_update_1130_delete_unpublished_variation_lookup_rows',
 			'wc_update_1130_split_tax_lookup_taxable_amount',
 			'wc_update_1130_delete_stock_notification_sync_notice_option',
+			'wc_update_1130_backfill_order_stats_payment_method',
 		),
 	);

@@ -1488,7 +1489,8 @@ class WC_Install {
 	net_total double DEFAULT 0 NOT NULL,
 	returning_customer tinyint(1) DEFAULT NULL,
 	status varchar(20) NOT NULL,
-	customer_id bigint(20) unsigned NOT NULL" .
+	customer_id bigint(20) unsigned NOT NULL,
+	payment_method varchar(100) DEFAULT NULL" .
 		( $should_have_fulfillment_column ? ',
 	fulfillment_status varchar(50) DEFAULT NULL' : '' ) . ',
 	PRIMARY KEY (order_id),
@@ -1818,6 +1820,7 @@ class WC_Install {
 		// Clear table caches.
 		delete_transient( 'wc_attribute_taxonomies' );
 		TaxesDataStore::flush_lookup_columns_cache();
+		OrdersStatsDataStore::flush_payment_method_column_cache();

 		return $db_delta_result;
 	}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 92ffdc7be76..fbea245c1f0 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -19,6 +19,7 @@
 defined( 'ABSPATH' ) || exit;

 use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Admin\Notes\Note;
 use Automattic\WooCommerce\Admin\Notes\Notes;
 use Automattic\WooCommerce\Database\Migrations\MigrationHelper;
@@ -4129,3 +4130,89 @@ function wc_update_1130_delete_unpublished_variation_lookup_rows() {
 function wc_update_1130_delete_stock_notification_sync_notice_option(): void {
 	delete_option( 'wc_customer_stock_notifications_product_sync_notice' );
 }
+
+/**
+ * Backfill the payment method on Analytics order stats rows written before the column existed.
+ *
+ * A database error is logged and stops the backfill without a retry.
+ *
+ * @since 11.3.0
+ *
+ * @return bool True when another batch is due, false when done or stopped.
+ */
+function wc_update_1130_backfill_order_stats_payment_method() {
+	global $wpdb;
+
+	$stats_table    = $wpdb->prefix . 'wc_order_stats';
+	$last_id_option = 'woocommerce_update_1130_last_payment_method_order_id';
+
+	if ( $stats_table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $stats_table ) ) ) {
+		return false;
+	}
+
+	$order_ids = $wpdb->get_col(
+		$wpdb->prepare(
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+			"SELECT order_id FROM {$stats_table} WHERE order_id > %d ORDER BY order_id ASC LIMIT 500",
+			(int) get_option( $last_id_option, 0 )
+		)
+	);
+
+	if ( '' === $wpdb->last_error && $order_ids ) {
+		$id_list = implode( ',', array_map( 'absint', $order_ids ) );
+
+		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table names and the id list are code-defined.
+		if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+			$orders_table = OrdersTableDataStore::get_orders_table_name();
+			$from_orders  = "UPDATE {$stats_table} stats
+				JOIN {$orders_table} orders ON orders.id = stats.order_id
+				SET stats.payment_method = orders.payment_method
+				WHERE stats.order_id IN ({$id_list})
+				AND stats.payment_method IS NULL
+				AND orders.payment_method != ''";
+			$refund_join  = "JOIN {$orders_table} refund_orders ON refund_orders.id = refunds.order_id AND refund_orders.type = 'shop_order_refund'";
+		} else {
+			$from_orders = "UPDATE {$stats_table} stats
+				JOIN {$wpdb->postmeta} meta ON meta.post_id = stats.order_id AND meta.meta_key = '_payment_method'
+				SET stats.payment_method = meta.meta_value
+				WHERE stats.order_id IN ({$id_list})
+				AND stats.payment_method IS NULL
+				AND meta.meta_value != ''";
+			$refund_join = "JOIN {$wpdb->posts} refund_orders ON refund_orders.ID = refunds.order_id AND refund_orders.post_type = 'shop_order_refund'";
+		}
+
+		// A refund holds no payment method of its own, so it takes the refunded order's. That order has the lower id, so its row is already filled.
+		// The join on the order type leaves out other child orders, which the sync stores without the parent's payment method.
+		$from_parents = "UPDATE {$stats_table} refunds
+			{$refund_join}
+			JOIN {$stats_table} parents ON parents.order_id = refunds.parent_id
+			SET refunds.payment_method = parents.payment_method
+			WHERE refunds.order_id IN ({$id_list})
+			AND refunds.parent_id != 0
+			AND refunds.payment_method IS NULL
+			AND parents.payment_method IS NOT NULL";
+
+		$updated = false !== $wpdb->query( $from_orders ) && false !== $wpdb->query( $from_parents );
+		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
+
+		if ( $updated ) {
+			update_option( $last_id_option, (int) end( $order_ids ), false );
+
+			return true;
+		}
+	}
+
+	if ( '' !== $wpdb->last_error ) {
+		wc_get_logger()->error(
+			sprintf( 'Stopped backfilling the Analytics payment method: %s', $wpdb->last_error ),
+			array( 'source' => 'wc-updater' )
+		);
+	}
+
+	delete_option( $last_id_option );
+
+	// The report rows cached before the column was filled would otherwise be served for a week.
+	ReportsCache::invalidate();
+
+	return false;
+}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
index d0b2075f22c..835ebc1ca5b 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
@@ -193,6 +193,12 @@ class Controller extends GenericController implements ExportableInterface {
 					'context'     => array( 'view', 'edit' ),
 					'readonly'    => true,
 				),
+				'payment_method'   => array(
+					'description' => __( 'Payment gateway ID the order was paid with.', 'woocommerce' ),
+					'type'        => 'string',
+					'context'     => array( 'view', 'edit' ),
+					'readonly'    => true,
+				),
 				'extended_info'    => array(
 					'products'    => array(
 						'type'        => 'array',
@@ -424,6 +430,25 @@ class Controller extends GenericController implements ExportableInterface {
 		return trim( ( $customer['first_name'] ?? '' ) . ' ' . ( $customer['last_name'] ?? '' ) );
 	}

+	/**
+	 * Get the merchant-facing title of a payment gateway, or its stored id when it has no title or is no longer installed.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $payment_method Payment gateway id.
+	 * @return string
+	 */
+	protected function get_payment_method_title( $payment_method ) {
+		if ( ! $payment_method ) {
+			return '';
+		}
+
+		$gateways = WC()->payment_gateways()->payment_gateways();
+		$title    = isset( $gateways[ $payment_method ] ) ? $gateways[ $payment_method ]->get_method_title() : '';
+
+		return $title ? $title : $payment_method;
+	}
+
 	/**
 	 * Get products column export value.
 	 *
@@ -471,8 +496,9 @@ class Controller extends GenericController implements ExportableInterface {
 			'products'        => __( 'Product(s)', 'woocommerce' ),
 			'num_items_sold'  => __( 'Items sold', 'woocommerce' ),
 			'coupons'         => __( 'Coupon(s)', 'woocommerce' ),
-			'net_total' 	  => __( 'Net Sales', 'woocommerce' ),
+			'net_total'       => __( 'Net Sales', 'woocommerce' ),
 			'attribution'     => __( 'Attribution', 'woocommerce' ),
+			'payment_method'  => __( 'Payment method', 'woocommerce' ),
 		);

 		/**
@@ -504,8 +530,9 @@ class Controller extends GenericController implements ExportableInterface {
 			'products'        => isset( $item['extended_info']['products'] ) ? $this->get_products( $item['extended_info']['products'] ) : null,
 			'num_items_sold'  => $item['num_items_sold'],
 			'coupons'         => isset( $item['extended_info']['coupons'] ) ? $this->get_coupons( $item['extended_info']['coupons'] ) : null,
-			'net_total' 	  => $item['net_total'],
+			'net_total'       => $item['net_total'],
 			'attribution'     => $item['extended_info']['attribution']['origin'],
+			'payment_method'  => $this->get_payment_method_title( $item['payment_method'] ?? '' ),
 		);

 		/**
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
index e5e6730ea01..94dfa835e48 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
@@ -81,6 +81,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		'total_sales'      => 'floatval',
 		'num_items_sold'   => 'intval',
 		'customer_type'    => 'strval',
+		'payment_method'   => 'strval',
 	);

 	/**
@@ -124,6 +125,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'total_sales'      => "{$table_name}.total_sales",
 			'num_items_sold'   => "{$table_name}.num_items_sold",
 			'customer_type'    => "(CASE WHEN {$returning_customer} = 0 THEN 'new' ELSE 'returning' END) as customer_type",
+			'payment_method'   => "{$table_name}.payment_method",
 		);
 	}

diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
index 7e325df8f4e..03f9e9838ba 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -579,6 +579,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'returning_customer' => $order->is_returning_customer(),
 		);

+		if ( self::has_payment_method_column() ) {
+			// An order without a gateway is stored as NULL, the same as the backfill leaves it. A refund
+			// carries no payment method of its own; the refund branch below takes the parent's.
+			$data['payment_method'] = $order instanceof WC_Order && $order->get_payment_method() ? $order->get_payment_method() : null;
+			$format[]               = '%s';
+		}
+
 		$order_fulfillment_status = '';
 		if ( FeaturesUtil::feature_is_enabled( 'fulfillments' ) && true === self::has_fulfillment_status_column() && $order instanceof WC_Order ) {
 			$order_fulfillment_status   = FulfillmentUtils::get_order_fulfillment_status( $order );
@@ -603,6 +610,10 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 				$data['parent_id'] = $parent_order->get_id();
 				$data['status']    = self::normalize_order_status( $parent_order->get_status() );

+				if ( $parent_order instanceof WC_Order && array_key_exists( 'payment_method', $data ) ) {
+					$data['payment_method'] = $parent_order->get_payment_method() ? $parent_order->get_payment_method() : null;
+				}
+
 				$refund_type               = $order->get_meta( '_refund_type' );
 				$uses_new_full_refund_data = OrderUtil::uses_new_full_refund_data();
 				$use_parent_refund_amounts = $uses_new_full_refund_data && (
@@ -802,6 +813,47 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		return $refund_total === $order_total;
 	}

+	/**
+	 * Whether the wc_order_stats table has the payment_method column, read once per request and keyed by blog id.
+	 *
+	 * @var array<int, bool>
+	 */
+	private static $has_payment_method_column = array();
+
+	/**
+	 * Forget whether the payment_method column exists, so the next check asks the schema again.
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 * @since 11.3.0
+	 *
+	 * @return void
+	 */
+	public static function flush_payment_method_column_cache() {
+		self::$has_payment_method_column = array();
+	}
+
+	/**
+	 * Check if the wc_order_stats table has the payment_method column.
+	 *
+	 * An order synced after an update and before dbDelta adds the column would otherwise lose its whole row.
+	 *
+	 * @return bool
+	 */
+	private static function has_payment_method_column(): bool {
+		global $wpdb;
+
+		$blog_id = get_current_blog_id();
+
+		if ( ! isset( self::$has_payment_method_column[ $blog_id ] ) ) {
+			$table_name = self::get_db_table_name();
+
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+			self::$has_payment_method_column[ $blog_id ] = (bool) $wpdb->get_var( "SHOW COLUMNS FROM `{$table_name}` LIKE 'payment_method'" );
+		}
+
+		return self::$has_payment_method_column[ $blog_id ];
+	}
+
 	/**
 	 * Check if the wc_order_stats table has the fulfillment_status column.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php
index 3ad158c1e96..27627531f62 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings.php
@@ -83,6 +83,20 @@ class Settings {
 		return array_combine( $formatted_statuses, $formatted_statuses );
 	}

+	/**
+	 * Get the titles of the installed payment gateways, for the payment method column of the Orders report.
+	 *
+	 * @return array Payment gateway titles, keyed by gateway id.
+	 */
+	private function get_payment_gateway_titles() {
+		$titles = array();
+		foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) {
+			$titles[ $gateway->id ] = $gateway->get_method_title();
+		}
+
+		return $titles;
+	}
+
 	/**
 	 * Validate a filtered list of default order statuses for display in the Settings UI.
 	 *
@@ -382,6 +396,7 @@ class Settings {
 			$settings['unregisteredOrderStatuses'] = $this->get_unregistered_order_statuses();
 			$settings['usesNewFullRefundData']     = OrderUtil::uses_new_full_refund_data();
 			$settings['couponTypes']               = wc_get_coupon_types();
+			$settings['paymentGateways']           = $this->get_payment_gateway_titles();
 		}

 		// The separator used for attributes found in Variation titles.
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-orders.php
index eb83caa7c76..7c55d586a6a 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-orders.php
@@ -168,7 +168,7 @@ class WC_Admin_Tests_API_Reports_Orders extends WC_REST_Unit_Test_Case {
 		$data       = $response->get_data();
 		$properties = $data['schema']['properties'];

-		$this->assertEquals( 11, count( $properties ) );
+		$this->assertEquals( 12, count( $properties ) );
 		$this->assertArrayHasKey( 'date_created_gmt', $properties );
 		$this->assertArrayHasKey( 'order_id', $properties );
 		$this->assertArrayHasKey( 'order_number', $properties );
@@ -179,6 +179,7 @@ class WC_Admin_Tests_API_Reports_Orders extends WC_REST_Unit_Test_Case {
 		$this->assertArrayHasKey( 'num_items_sold', $properties );
 		$this->assertArrayHasKey( 'total_formatted', $properties );
 		$this->assertArrayHasKey( 'customer_type', $properties );
+		$this->assertArrayHasKey( 'payment_method', $properties );
 		$this->assertArrayHasKey( 'extended_info', $properties );
 	}

diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
index 8c08b474abd..36038b2e643 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-orders.php
@@ -92,6 +92,7 @@ class WC_Admin_Tests_Reports_Orders extends WC_Unit_Test_Case {
 					'num_items_sold'   => 5,
 					'customer_id'      => $data->data[0]['customer_id'], // Not under test.
 					'customer_type'    => 'new',
+					'payment_method'   => 'bacs',
 					'date_created'     => $data->data[0]['date_created'], // Not under test.
 					'date_created_gmt' => $data->data[0]['date_created_gmt'], // Not under test.
 					'date'             => $data->data[0]['date'], // Not under test.
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index 358e4e05473..ecf3856cf7e 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -7,11 +7,13 @@

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
 use Automattic\WooCommerce\Admin\Notes\Note;
 use Automattic\WooCommerce\Admin\Notes\Notes;
 use Automattic\WooCommerce\Blocks\InboxNotifications;
 use Automattic\WooCommerce\Blocks\Options as BlockOptions;
 use Automattic\WooCommerce\Blocks\Utils\BlockTemplateUtils;
+use Automattic\WooCommerce\Enums\OrderInternalStatus;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
@@ -1170,4 +1172,157 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		wc_update_1130_delete_stock_notification_sync_notice_option();
 		$this->assertFalse( get_option( 'wc_customer_stock_notifications_product_sync_notice' ) );
 	}
+
+	/**
+	 * @testdox Migration backfills the payment method on order and refund rows, then clears its cursor and the report cache.
+	 */
+	public function test_wc_update_1130_backfill_order_stats_payment_method(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_payment_method( 'cheque' );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+		$this->assertNotWPError( $refund );
+		OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+		// A child order that is not a refund, such as a scheduled payment, has no gateway until it is paid.
+		$child_order = wc_create_order( array( 'parent' => $order->get_id() ) );
+		OrdersStatsDataStore::sync_order( $child_order->get_id() );
+
+		$stats_table = $wpdb->prefix . 'wc_order_stats';
+
+		$get_payment_method = function ( $order_id ) use ( $wpdb, $stats_table ) {
+			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is code-defined.
+			return $wpdb->get_var( $wpdb->prepare( "SELECT payment_method FROM {$stats_table} WHERE order_id = %d", $order_id ) );
+		};
+
+		// Reproduce the rows as they were written before the column existed.
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is code-defined.
+		$wpdb->query( "UPDATE {$stats_table} SET payment_method = NULL" );
+		$this->assertNull( $get_payment_method( $order->get_id() ), 'The order row should start without a payment method.' );
+
+		$cache_key        = 'wc_update_1130_payment_method_report';
+		$version_key      = ReportsCache::VERSION_OPTION . '-transient-version';
+		$original_version = get_transient( $version_key );
+		set_transient( $version_key, 'stale-version' );
+
+		try {
+			ReportsCache::set( $cache_key, 'stale-value' );
+			$this->assertSame( 'stale-value', ReportsCache::get( $cache_key ) );
+
+			$batches = 0;
+			while ( wc_update_1130_backfill_order_stats_payment_method() ) {
+				++$batches;
+				$this->assertLessThan( 10, $batches, 'The migration reschedules itself until every batch is done.' );
+			}
+
+			$this->assertFalse( ReportsCache::get( $cache_key ), 'The report rows cached without a payment method are invalidated on completion.' );
+		} finally {
+			delete_transient( $cache_key );
+			if ( false === $original_version ) {
+				delete_transient( $version_key );
+			} else {
+				set_transient( $version_key, $original_version );
+			}
+		}
+
+		$this->assertSame( 'cheque', $get_payment_method( $order->get_id() ) );
+		$this->assertSame( 'cheque', $get_payment_method( $refund->get_id() ), 'A refund takes the payment method of the order it refunds.' );
+		$this->assertNull( $get_payment_method( $child_order->get_id() ), 'A child order that is not a refund does not take the payment method of its parent.' );
+		$this->assertFalse( get_option( 'woocommerce_update_1130_last_payment_method_order_id' ), 'The cursor is cleared on completion.' );
+	}
+
+	/**
+	 * @testdox Migration carries its cursor between batches, so it reaches the rows past the first batch.
+	 */
+	public function test_wc_update_1130_backfill_order_stats_payment_method_in_batches(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$stats_table = $wpdb->prefix . 'wc_order_stats';
+		$cursor      = 'woocommerce_update_1130_last_payment_method_order_id';
+		$date        = gmdate( 'Y-m-d H:i:s' );
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is code-defined.
+		$first_id = (int) $wpdb->get_var( "SELECT MAX(order_id) FROM {$stats_table}" ) + 1;
+		$last_id  = $first_id + 500;
+
+		// One full batch of rows and one row more. The cursor is under test, so the rows need no order behind them.
+		for ( $order_id = $first_id; $order_id <= $last_id; $order_id++ ) {
+			$wpdb->insert(
+				$stats_table,
+				array(
+					'order_id'         => $order_id,
+					'status'           => OrderInternalStatus::COMPLETED,
+					'customer_id'      => 0,
+					'date_created'     => $date,
+					'date_created_gmt' => $date,
+				),
+				array( '%d', '%s', '%d', '%s', '%s' )
+			);
+		}
+
+		// Start past the rows other tests may have left, so the batches hold only the rows above.
+		update_option( $cursor, $first_id - 1, false );
+
+		$this->assertTrue( wc_update_1130_backfill_order_stats_payment_method(), 'A full batch leaves work for another run.' );
+		$this->assertSame( $first_id + 499, (int) get_option( $cursor ), 'The cursor is saved at the last id of the batch.' );
+
+		$this->assertTrue( wc_update_1130_backfill_order_stats_payment_method(), 'The second batch resumes after the cursor.' );
+		$this->assertSame( $last_id, (int) get_option( $cursor ), 'The second batch takes the row past the first batch.' );
+
+		$this->assertFalse( wc_update_1130_backfill_order_stats_payment_method(), 'An empty batch ends the migration.' );
+		$this->assertFalse( get_option( $cursor ), 'The cursor is cleared on completion.' );
+	}
+
+	/**
+	 * @testdox Migration stops at a failed write, clears its cursor and leaves the row without a payment method.
+	 */
+	public function test_wc_update_1130_backfill_order_stats_payment_method_stops_on_failed_write(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_payment_method( 'cheque' );
+		$order->save();
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+
+		$stats_table = $wpdb->prefix . 'wc_order_stats';
+		$cursor      = 'woocommerce_update_1130_last_payment_method_order_id';
+
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is code-defined.
+		$wpdb->query( "UPDATE {$stats_table} SET payment_method = NULL" );
+		update_option( $cursor, $order->get_id() - 1, false );
+
+		$break_update = function ( $query ) use ( $stats_table ) {
+			return 0 === strpos( $query, "UPDATE {$stats_table} " ) ? "UPDATE {$stats_table} SET no_such_column = 1" : $query;
+		};
+		add_filter( 'query', $break_update );
+		$suppressed = $wpdb->suppress_errors();
+
+		try {
+			$this->assertFalse( wc_update_1130_backfill_order_stats_payment_method(), 'A failed write does not request another run.' );
+		} finally {
+			$wpdb->suppress_errors( $suppressed );
+			remove_filter( 'query', $break_update );
+		}
+
+		$this->assertFalse( get_option( $cursor ), 'The cursor saved by an earlier batch is cleared after a failed write.' );
+
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is code-defined.
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT payment_method FROM {$stats_table} WHERE order_id = %d", $order->get_id() ), ARRAY_A );
+		$this->assertSame( array( 'payment_method' => null ), $row, 'The row keeps a NULL payment method after a failed write.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/ControllerTest.php
index ea69505bdfe..9af9868feb9 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/ControllerTest.php
@@ -61,19 +61,66 @@ class ControllerTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'Ada', $export_item['customer_name'] );
 	}

+	/**
+	 * @testdox The payment method export column should hold the title of an installed gateway.
+	 */
+	public function test_payment_method_export_column_holds_the_gateway_title(): void {
+		$export_item = $this->sut->prepare_item_for_export( $this->get_item( array(), 'bacs' ) );
+
+		$this->assertSame( 'Direct bank transfer', $export_item['payment_method'] );
+	}
+
+	/**
+	 * @testdox The payment method export column should fall back to the stored id for a gateway that is gone.
+	 */
+	public function test_payment_method_export_column_falls_back_to_the_stored_id(): void {
+		$export_item = $this->sut->prepare_item_for_export( $this->get_item( array(), 'a_removed_gateway' ) );
+
+		$this->assertSame( 'a_removed_gateway', $export_item['payment_method'] );
+	}
+
+	/**
+	 * @testdox The payment method export column should fall back to the stored id for an installed gateway without a title.
+	 */
+	public function test_payment_method_export_column_falls_back_to_the_stored_id_without_a_title(): void {
+		add_filter(
+			'woocommerce_gateway_method_title',
+			function ( $title, $gateway ) {
+				return 'bacs' === $gateway->id ? '' : $title;
+			},
+			10,
+			2
+		);
+
+		$export_item = $this->sut->prepare_item_for_export( $this->get_item( array(), 'bacs' ) );
+
+		$this->assertSame( 'bacs', $export_item['payment_method'] );
+	}
+
+	/**
+	 * @testdox The payment method export column should be empty for an order placed without a gateway.
+	 */
+	public function test_payment_method_export_column_is_empty_without_a_gateway(): void {
+		$export_item = $this->sut->prepare_item_for_export( $this->get_item( array(), '' ) );
+
+		$this->assertSame( '', $export_item['payment_method'] );
+	}
+
 	/**
 	 * Build a report row carrying the given customer record.
 	 *
-	 * @param array $customer Customer record for the row.
+	 * @param array  $customer       Customer record for the row.
+	 * @param string $payment_method Payment gateway id for the row.
 	 * @return array
 	 */
-	private function get_item( array $customer ): array {
+	private function get_item( array $customer, string $payment_method = '' ): array {
 		return array(
 			'date'            => '2026-09-04 10:00:00',
 			'order_number'    => '123',
 			'total_formatted' => '10.00',
 			'status'          => OrderStatus::COMPLETED,
 			'customer_type'   => 'new',
+			'payment_method'  => $payment_method,
 			'num_items_sold'  => 1,
 			'net_total'       => 10.00,
 			'extended_info'   => array(
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/PaymentMethodTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/PaymentMethodTest.php
new file mode 100644
index 00000000000..e46685dbf2b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/PaymentMethodTest.php
@@ -0,0 +1,187 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Orders;
+
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Helper_Order;
+use WC_REST_Unit_Test_Case;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * Tests for reporting the Orders report on the payment gateway an order was paid with.
+ */
+class PaymentMethodTest extends WC_REST_Unit_Test_Case {
+
+	/**
+	 * Orders created by the test, keyed by the gateway they were paid with.
+	 *
+	 * @var array
+	 */
+	private $orders = array();
+
+	/**
+	 * Date the fixture orders are created and paid on, inside the reporting period queried below.
+	 */
+	private const ORDER_DATE = '2026-09-04 10:00:00';
+
+	/**
+	 * Set up one completed order per gateway.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( array( 'bacs', 'cheque', 'cod' ) as $gateway ) {
+			$order = WC_Helper_Order::create_order();
+			$order->set_payment_method( $gateway );
+			$order->set_status( OrderStatus::COMPLETED );
+			$order->set_date_created( self::ORDER_DATE );
+			// The report runs on date_paid by default, and completing an order otherwise stamps it with the current time.
+			$order->set_date_paid( self::ORDER_DATE );
+			$order->save();
+
+			OrdersStatsDataStore::sync_order( $order->get_id() );
+
+			$this->orders[ $gateway ] = $order;
+		}
+	}
+
+	/**
+	 * @testdox Syncing an order stores the gateway it was paid with.
+	 */
+	public function test_sync_stores_the_payment_method(): void {
+		$this->assertSame( 'bacs', $this->get_stored_payment_method( $this->orders['bacs']->get_id() ) );
+	}
+
+	/**
+	 * @testdox A refund is stored with the gateway of the order it refunds, which carries none of its own.
+	 */
+	public function test_refund_takes_the_payment_method_of_the_refunded_order(): void {
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $this->orders['cheque']->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+
+		$this->assertNotInstanceOf( WP_Error::class, $refund );
+
+		OrdersStatsDataStore::sync_order( $refund->get_id() );
+
+		$this->assertSame( 'cheque', $this->get_stored_payment_method( $refund->get_id() ) );
+	}
+
+	/**
+	 * @testdox Syncing an order placed without a gateway, or a refund of it, stores NULL, the value the backfill leaves on such a row.
+	 */
+	public function test_sync_stores_null_for_an_order_without_a_gateway(): void {
+		global $wpdb;
+
+		$order = $this->orders['cod'];
+		$order->set_payment_method( '' );
+		$order->save();
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+
+		foreach ( array( $order->get_id(), $refund->get_id() ) as $id ) {
+			OrdersStatsDataStore::sync_order( $id );
+
+			$this->assertSame(
+				'1',
+				$wpdb->get_var(
+					$wpdb->prepare(
+						"SELECT payment_method IS NULL FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+						$id
+					)
+				),
+				'No gateway should be stored as NULL, not as an empty string.'
+			);
+		}
+	}
+
+	/**
+	 * @testdox Syncing an order and its refund before the update adds the payment method column still writes their rows.
+	 */
+	public function test_sync_writes_the_row_while_the_payment_method_column_is_missing(): void {
+		global $wpdb;
+
+		$order  = $this->orders['cheque'];
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $order->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+
+		$hide_column = function ( $query ) use ( $wpdb ) {
+			return str_replace( "SHOW COLUMNS FROM `{$wpdb->prefix}wc_order_stats` LIKE 'payment_method'", 'SELECT NULL', $query );
+		};
+		add_filter( 'query', $hide_column );
+		OrdersStatsDataStore::flush_payment_method_column_cache();
+
+		try {
+			foreach ( array( $order->get_id(), $refund->get_id() ) as $id ) {
+				$this->assertTrue( OrdersStatsDataStore::sync_order( $id ), 'The row should be written without the payment method.' );
+				$this->assertNull( $this->get_stored_payment_method( $id ), 'The backfill fills the payment method once the column exists.' );
+			}
+		} finally {
+			remove_filter( 'query', $hide_column );
+			OrdersStatsDataStore::flush_payment_method_column_cache();
+		}
+	}
+
+	/**
+	 * @testdox The Orders report endpoint returns the gateway of each row.
+	 */
+	public function test_orders_endpoint_returns_the_payment_method_of_each_row(): void {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$request = new WP_REST_Request( 'GET', '/wc-analytics/reports/orders' );
+		$request->set_query_params( $this->get_period_args() );
+		$response = rest_do_request( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$payment_methods = wp_list_pluck( $response->get_data(), 'payment_method', 'order_id' );
+		foreach ( $this->orders as $gateway => $order ) {
+			$this->assertSame( $gateway, $payment_methods[ $order->get_id() ] ?? null );
+		}
+	}
+
+	/**
+	 * Read the payment method stored for an order in the stats table.
+	 *
+	 * @param int $order_id Order id.
+	 * @return string|null
+	 */
+	private function get_stored_payment_method( int $order_id ) {
+		global $wpdb;
+
+		return $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT payment_method FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d",
+				$order_id
+			)
+		);
+	}
+
+	/**
+	 * The reporting period and cache settings shared by every query in this test.
+	 *
+	 * @return array
+	 */
+	private function get_period_args(): array {
+		return array(
+			'after'               => '2026-09-01T00:00:00',
+			'before'              => '2026-09-30T23:59:59',
+			'per_page'            => 100,
+			'force_cache_refresh' => true,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
index 542b018a990..acce18d7ed0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
@@ -333,6 +333,33 @@ class SettingsTest extends WC_Unit_Test_Case {
 		$this->assertArrayHasKey( 'percent', $settings['couponTypes'], 'Core discount types should still be included.' );
 	}

+	/**
+	 * @testdox The preloaded component settings list the installed payment gateways by title.
+	 */
+	public function test_component_settings_expose_payment_gateway_titles(): void {
+		// Payment gateways are only injected on wc-admin pages; the base tearDown unsets the current screen.
+		set_current_screen( 'dashboard' );
+		$_GET['page'] = 'wc-admin'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+		try {
+			$settings = $this->sut->add_component_settings( array() );
+		} finally {
+			unset( $_GET['page'] );
+		}
+
+		$this->assertSame( array_keys( WC()->payment_gateways()->payment_gateways() ), array_keys( $settings['paymentGateways'] ), 'Every installed gateway should be listed, in their order.' );
+		$this->assertSame( 'Direct bank transfer', $settings['paymentGateways']['bacs'], 'An installed gateway should be listed by its title.' );
+	}
+
+	/**
+	 * @testdox The preloaded component settings leave the payment gateways out outside the wc-admin app, which is the only place that reads them.
+	 */
+	public function test_component_settings_leave_payment_gateways_out_outside_wc_admin(): void {
+		set_current_screen( 'dashboard' );
+
+		$this->assertArrayNotHasKey( 'paymentGateways', $this->sut->add_component_settings( array() ) );
+	}
+
 	/**
 	 * @testdox Saving an order status list that matches the default deletes the option, so the default filter keeps applying.
 	 */