Commit 96d526cd2b2 for woocommerce

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

    Add a payment method filter to the Analytics Orders report (#69651)

    * Add a payment method filter to the Analytics Orders report

    * Read the payment method filter options through a cached Orders data store method

    * Add a payment method and date paid index to the order stats table

    EXPLAIN on 1M orders showed the filter queries and the filter options DISTINCT query doing full table scans. A payment_method-only key made a dominant gateway over a year slower, and a (date, payment_method) key didn't change the plan. (payment_method, date_paid) covers the default date type and the options query.

    * Add the status to the order stats payment method index

    With the status in the index, the Orders report count query reads only the index. On 1M orders that takes a dominant gateway over a year from a 259ms full scan to 37ms. Other report queries stay the same, and the index grows from 31.6MB to 46.6MB.

diff --git a/plugins/woocommerce/changelog/47283-analytics-orders-payment-method-filter b/plugins/woocommerce/changelog/47283-analytics-orders-payment-method-filter
new file mode 100644
index 00000000000..d32290e6614
--- /dev/null
+++ b/plugins/woocommerce/changelog/47283-analytics-orders-payment-method-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Analytics: add a payment method advanced filter to the Orders report.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/orders/config.js b/plugins/woocommerce/client/admin/client/analytics/report/orders/config.js
index 139a14a5b6b..ec0927a17cb 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/orders/config.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/orders/config.js
@@ -13,7 +13,7 @@ import {
 	getTaxRateLabels,
 	getVariationLabels,
 } from '../../../lib/async-requests';
-import { ORDER_STATUSES } from '~/utils/admin-settings';
+import { ORDER_STATUSES, PAYMENT_GATEWAYS } from '~/utils/admin-settings';

 const ORDERS_REPORT_CHARTS_FILTER = 'woocommerce_admin_orders_report_charts';
 const ORDERS_REPORT_FILTERS_FILTER = 'woocommerce_admin_orders_report_filters';
@@ -264,6 +264,40 @@ export const advancedFilters = applyFilters(
 					defaultOption: 'new',
 				},
 			},
+			payment_method: {
+				labels: {
+					add: __( 'Payment method', 'woocommerce' ),
+					remove: __( 'Remove payment method filter', 'woocommerce' ),
+					rule: __(
+						'Select a payment method filter match',
+						'woocommerce'
+					),
+					/* translators: A sentence describing a Payment method filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
+					title: __(
+						'<title>Payment method</title> <rule/> <filter/>',
+						'woocommerce'
+					),
+					filter: __( 'Select a payment method', 'woocommerce' ),
+				},
+				rules: [
+					{
+						value: 'is',
+						/* translators: Sentence fragment, logical, "Is" refers to searching for orders paid with a chosen payment method. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
+						label: _x( 'Is', 'payment method', 'woocommerce' ),
+					},
+					{
+						value: 'is_not',
+						/* translators: Sentence fragment, logical, "Is Not" refers to searching for orders not paid with a chosen payment method. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
+						label: _x( 'Is Not', 'payment method', 'woocommerce' ),
+					},
+				],
+				input: {
+					component: 'SelectControl',
+					options: Object.entries( PAYMENT_GATEWAYS ).map(
+						( [ value, label ] ) => ( { value, label } )
+					),
+				},
+			},
 			refunds: {
 				labels: {
 					add: __( 'Refund', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 17ccf82509c..b6aa1f6bfbc 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -1499,7 +1499,8 @@ class WC_Install {
 	KEY status (status)' .
 		( $should_have_fulfillment_column ? ',
 	KEY fulfillment_status (fulfillment_status)' : '' ) . ",
-	KEY idx_date_paid_status_parent (date_paid, status, parent_id)
+	KEY idx_date_paid_status_parent (date_paid, status, parent_id),
+	KEY idx_payment_method_date_paid_status (payment_method, date_paid, status)
 ) $collate;";
 	}

diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index fbea245c1f0..4ecf878915a 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -20,6 +20,7 @@ defined( 'ABSPATH' ) || exit;

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
 use Automattic\WooCommerce\Admin\Notes\Note;
 use Automattic\WooCommerce\Admin\Notes\Notes;
 use Automattic\WooCommerce\Database\Migrations\MigrationHelper;
@@ -4213,6 +4214,8 @@ function wc_update_1130_backfill_order_stats_payment_method() {

 	// The report rows cached before the column was filled would otherwise be served for a week.
 	ReportsCache::invalidate();
+	// The same goes for the Orders report payment method filter, cached for a year.
+	wp_cache_delete( OrdersDataStore::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, 'woocommerce_analytics' );

 	return false;
 }
diff --git a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
index 2b7aa8d2eab..7f163dde402 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/DataStore.php
@@ -1488,6 +1488,60 @@ class DataStore extends SqlQuery implements DataStoreInterface {
 		}
 	}

+	/**
+	 * Returns payment gateway subquery to be used in WHERE SQL query, based on query arguments from the user.
+	 *
+	 * The include and exclude clauses are always joined with AND, so that "is bacs" and "is not cheque"
+	 * narrow the result together even under `match=any`.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param array $query_args Parameters supplied by the user.
+	 * @return string
+	 */
+	protected function get_payment_method_subquery( $query_args ) {
+		global $wpdb;
+
+		$table      = $wpdb->prefix . 'wc_order_stats';
+		$subqueries = array();
+
+		$included = $this->get_payment_method_list( $query_args, 'payment_method_is' );
+		if ( $included ) {
+			$subqueries[] = "{$table}.payment_method IN ( {$included} )";
+		}
+
+		$excluded = $this->get_payment_method_list( $query_args, 'payment_method_is_not' );
+		if ( $excluded ) {
+			// NOT IN would drop the NULL rows, which are not the excluded gateway either.
+			$subqueries[] = "( {$table}.payment_method IS NULL OR {$table}.payment_method NOT IN ( {$excluded} ) )";
+		}
+
+		return implode( ' AND ', $subqueries );
+	}
+
+	/**
+	 * Build a quoted SQL list from the payment gateway ids held in a query argument.
+	 *
+	 * @param array  $query_args Parameters supplied by the user.
+	 * @param string $field      Query argument holding the ids.
+	 * @return string Comma separated list of quoted ids, empty when there is nothing to filter on.
+	 */
+	private function get_payment_method_list( $query_args, $field ) {
+		$payment_methods = isset( $query_args[ $field ] ) && is_array( $query_args[ $field ] ) ? $query_args[ $field ] : array();
+		$payment_methods = array_filter(
+			array_map( 'strval', $payment_methods ),
+			function ( $payment_method ) {
+				return '' !== $payment_method;
+			}
+		);
+
+		if ( ! $payment_methods ) {
+			return '';
+		}
+
+		return "'" . implode( "','", array_map( 'esc_sql', $payment_methods ) ) . "'";
+	}
+
 	/**
 	 * Returns customer subquery to be used in WHERE SQL query, based on query arguments from the user.
 	 *
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
index 835ebc1ca5b..636f5b80ee2 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Controller.php
@@ -50,32 +50,34 @@ class Controller extends GenericController implements ExportableInterface {
 	 * @return array
 	 */
 	protected function prepare_reports_query( $request ) {
-		$args                        = array();
-		$args['before']              = $request['before'];
-		$args['after']               = $request['after'];
-		$args['page']                = $request['page'];
-		$args['per_page']            = $request['per_page'];
-		$args['orderby']             = $request['orderby'];
-		$args['order']               = $request['order'];
-		$args['product_includes']    = (array) $request['product_includes'];
-		$args['product_excludes']    = (array) $request['product_excludes'];
-		$args['variation_includes']  = (array) $request['variation_includes'];
-		$args['variation_excludes']  = (array) $request['variation_excludes'];
-		$args['coupon_includes']     = (array) $request['coupon_includes'];
-		$args['coupon_excludes']     = (array) $request['coupon_excludes'];
-		$args['tax_rate_includes']   = (array) $request['tax_rate_includes'];
-		$args['tax_rate_excludes']   = (array) $request['tax_rate_excludes'];
-		$args['status_is']           = (array) $request['status_is'];
-		$args['status_is_not']       = (array) $request['status_is_not'];
-		$args['customer_type']       = $request['customer_type'];
-		$args['extended_info']       = $request['extended_info'];
-		$args['refunds']             = $request['refunds'];
-		$args['match']               = $request['match'];
-		$args['order_includes']      = $request['order_includes'];
-		$args['order_excludes']      = $request['order_excludes'];
-		$args['attribute_is']        = (array) $request['attribute_is'];
-		$args['attribute_is_not']    = (array) $request['attribute_is_not'];
-		$args['force_cache_refresh'] = $request['force_cache_refresh'];
+		$args                          = array();
+		$args['before']                = $request['before'];
+		$args['after']                 = $request['after'];
+		$args['page']                  = $request['page'];
+		$args['per_page']              = $request['per_page'];
+		$args['orderby']               = $request['orderby'];
+		$args['order']                 = $request['order'];
+		$args['product_includes']      = (array) $request['product_includes'];
+		$args['product_excludes']      = (array) $request['product_excludes'];
+		$args['variation_includes']    = (array) $request['variation_includes'];
+		$args['variation_excludes']    = (array) $request['variation_excludes'];
+		$args['coupon_includes']       = (array) $request['coupon_includes'];
+		$args['coupon_excludes']       = (array) $request['coupon_excludes'];
+		$args['tax_rate_includes']     = (array) $request['tax_rate_includes'];
+		$args['tax_rate_excludes']     = (array) $request['tax_rate_excludes'];
+		$args['status_is']             = (array) $request['status_is'];
+		$args['status_is_not']         = (array) $request['status_is_not'];
+		$args['customer_type']         = $request['customer_type'];
+		$args['payment_method_is']     = (array) $request['payment_method_is'];
+		$args['payment_method_is_not'] = (array) $request['payment_method_is_not'];
+		$args['extended_info']         = $request['extended_info'];
+		$args['refunds']               = $request['refunds'];
+		$args['match']                 = $request['match'];
+		$args['order_includes']        = $request['order_includes'];
+		$args['order_excludes']        = $request['order_excludes'];
+		$args['attribute_is']          = (array) $request['attribute_is'];
+		$args['attribute_is_not']      = (array) $request['attribute_is_not'];
+		$args['force_cache_refresh']   = $request['force_cache_refresh'];

 		return $args;
 	}
@@ -237,16 +239,16 @@ class Controller extends GenericController implements ExportableInterface {
 	 * @return array
 	 */
 	public function get_collection_params() {
-		$params                        = parent::get_collection_params();
-		$params['per_page']['minimum'] = 0;
-		$params['orderby']['enum']     = $this->apply_custom_orderby_filters(
+		$params                          = parent::get_collection_params();
+		$params['per_page']['minimum']   = 0;
+		$params['orderby']['enum']       = $this->apply_custom_orderby_filters(
 			array(
 				'date',
 				'num_items_sold',
 				'net_total',
 			)
 		);
-		$params['product_includes']    = array(
+		$params['product_includes']      = array(
 			'description'       => __( 'Limit result set to items that have the specified product(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -256,7 +258,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['product_excludes']    = array(
+		$params['product_excludes']      = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified product(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -266,7 +268,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['variation_includes']  = array(
+		$params['variation_includes']    = array(
 			'description'       => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -276,7 +278,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['variation_excludes']  = array(
+		$params['variation_excludes']    = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -286,7 +288,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['coupon_includes']     = array(
+		$params['coupon_includes']       = array(
 			'description'       => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -296,7 +298,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['coupon_excludes']     = array(
+		$params['coupon_excludes']       = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -306,7 +308,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['tax_rate_includes']   = array(
+		$params['tax_rate_includes']     = array(
 			'description'       => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -316,7 +318,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['tax_rate_excludes']   = array(
+		$params['tax_rate_excludes']     = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -326,7 +328,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['status_is']           = array(
+		$params['status_is']             = array(
 			'description'       => __( 'Limit result set to items that have the specified order status.', 'woocommerce' ),
 			'type'              => 'array',
 			'sanitize_callback' => 'wp_parse_slug_list',
@@ -336,7 +338,7 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'string',
 			),
 		);
-		$params['status_is_not']       = array(
+		$params['status_is_not']         = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified order status.', 'woocommerce' ),
 			'type'              => 'array',
 			'sanitize_callback' => 'wp_parse_slug_list',
@@ -346,7 +348,7 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'string',
 			),
 		);
-		$params['customer_type']       = array(
+		$params['customer_type']         = array(
 			'description'       => __( 'Limit result set to returning or new customers.', 'woocommerce' ),
 			'type'              => 'string',
 			'default'           => '',
@@ -357,7 +359,27 @@ class Controller extends GenericController implements ExportableInterface {
 			),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['refunds']             = array(
+		$params['payment_method_is']     = array(
+			'description'       => __( 'Limit result set to items that were paid with the specified payment gateway(s).', 'woocommerce' ),
+			'type'              => 'array',
+			'default'           => array(),
+			'sanitize_callback' => 'wp_parse_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'string',
+			),
+		);
+		$params['payment_method_is_not'] = array(
+			'description'       => __( 'Limit result set to items that were not paid with the specified payment gateway(s).', 'woocommerce' ),
+			'type'              => 'array',
+			'default'           => array(),
+			'sanitize_callback' => 'wp_parse_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'string',
+			),
+		);
+		$params['refunds']               = array(
 			'description'       => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
 			'type'              => 'string',
 			'default'           => '',
@@ -370,14 +392,14 @@ class Controller extends GenericController implements ExportableInterface {
 			),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['extended_info']       = array(
+		$params['extended_info']         = array(
 			'description'       => __( 'Add additional piece of info about each coupon to the report.', 'woocommerce' ),
 			'type'              => 'boolean',
 			'default'           => false,
 			'sanitize_callback' => 'wc_string_to_bool',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['order_includes']      = array(
+		$params['order_includes']        = array(
 			'description'       => __( 'Limit result set to items that have the specified order ids.', 'woocommerce' ),
 			'type'              => 'array',
 			'sanitize_callback' => 'wp_parse_id_list',
@@ -386,7 +408,7 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'integer',
 			),
 		);
-		$params['order_excludes']      = array(
+		$params['order_excludes']        = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified order ids.', 'woocommerce' ),
 			'type'              => 'array',
 			'sanitize_callback' => 'wp_parse_id_list',
@@ -395,7 +417,7 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'integer',
 			),
 		);
-		$params['attribute_is']        = array(
+		$params['attribute_is']          = array(
 			'description'       => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -404,7 +426,7 @@ class Controller extends GenericController implements ExportableInterface {
 			'default'           => array(),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['attribute_is_not']    = array(
+		$params['attribute_is_not']      = array(
 			'description'       => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
index 94dfa835e48..6f4b79bb4cc 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/DataStore.php
@@ -26,6 +26,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	const ORDERS_STATUSES_ALL_CACHE_KEY = 'woocommerce_analytics_orders_statuses_all';

+	/**
+	 * The cache key for payment methods.
+	 */
+	const ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY = 'woocommerce_analytics_orders_payment_methods_all';
+
 	/**
 	 * Dynamically sets the date column name based on configuration
 	 *
@@ -43,6 +48,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	final public static function init() {
 		add_action( 'woocommerce_analytics_update_order_stats', array( __CLASS__, 'maybe_update_order_statuses_cache' ) );
+		add_action( 'woocommerce_analytics_update_order_stats', array( __CLASS__, 'maybe_update_payment_methods_cache' ) );
 	}

 	/**
@@ -172,6 +178,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			$where_subquery[]   = "{$order_stats_lookup_table}.returning_customer = {$returning_customer}";
 		}

+		$payment_method_subquery = $this->get_payment_method_subquery( $query_args );
+		if ( $payment_method_subquery ) {
+			$where_subquery[] = $payment_method_subquery;
+		}
+
 		$refund_subquery = $this->get_refund_subquery( $query_args );
 		$this->subquery->add_sql_clause( 'from', $refund_subquery['from_clause'] );
 		if ( $refund_subquery['where_clause'] ) {
@@ -261,19 +272,21 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$defaults = array_merge(
 			parent::get_default_query_vars(),
 			array(
-				'orderby'           => $this->date_column_name,
-				'product_includes'  => array(),
-				'product_excludes'  => array(),
-				'coupon_includes'   => array(),
-				'coupon_excludes'   => array(),
-				'tax_rate_includes' => array(),
-				'tax_rate_excludes' => array(),
-				'customer_type'     => null,
-				'status_is'         => array(),
-				'extended_info'     => false,
-				'refunds'           => null,
-				'order_includes'    => array(),
-				'order_excludes'    => array(),
+				'orderby'               => $this->date_column_name,
+				'product_includes'      => array(),
+				'product_excludes'      => array(),
+				'coupon_includes'       => array(),
+				'coupon_excludes'       => array(),
+				'tax_rate_includes'     => array(),
+				'tax_rate_excludes'     => array(),
+				'customer_type'         => null,
+				'payment_method_is'     => array(),
+				'payment_method_is_not' => array(),
+				'status_is'             => array(),
+				'extended_info'         => false,
+				'refunds'               => null,
+				'order_includes'        => array(),
+				'order_excludes'        => array(),
 			)
 		);

@@ -767,6 +780,51 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		self::maybe_update_order_statuses_cache( $order_id );
 	}

+	/**
+	 * Get all payment methods that have been synced.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @return string[] Unique payment method ids.
+	 */
+	public static function get_all_payment_methods() {
+		global $wpdb;
+
+		$payment_methods = wp_cache_get( self::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, 'woocommerce_analytics' );
+		if ( false === $payment_methods ) {
+			$table_name      = self::get_db_table_name();
+			$payment_methods = $wpdb->get_col(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be prepared.
+				"SELECT DISTINCT payment_method FROM {$table_name} WHERE payment_method != '' ORDER BY payment_method"
+			);
+			wp_cache_set( self::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, $payment_methods, 'woocommerce_analytics', YEAR_IN_SECONDS );
+		}
+
+		return $payment_methods;
+	}
+
+	/**
+	 * Ensure the order payment method is present in the `get_all_payment_methods` call result.
+	 *
+	 * A refund has no payment method of its own. The order it refunds adds it.
+	 *
+	 * @internal
+	 *
+	 * @param int $order_id Order ID.
+	 * @return void
+	 */
+	public static function maybe_update_payment_methods_cache( $order_id ) {
+		$order          = wc_get_order( $order_id );
+		$payment_method = $order instanceof \WC_Order ? $order->get_payment_method() : '';
+		if ( $payment_method ) {
+			$payment_methods = self::get_all_payment_methods();
+			if ( ! in_array( $payment_method, $payment_methods, true ) ) {
+				$payment_methods[] = $payment_method;
+				wp_cache_set( self::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, $payment_methods, 'woocommerce_analytics', YEAR_IN_SECONDS );
+			}
+		}
+	}
+
 	/**
 	 * Initialize query objects.
 	 */
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php
index f050291a0ce..db1c0432bc1 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/Controller.php
@@ -50,33 +50,35 @@ class Controller extends GenericStatsController {
 	 * @return array
 	 */
 	protected function prepare_reports_query( $request ) {
-		$args                        = array();
-		$args['before']              = $request['before'];
-		$args['after']               = $request['after'];
-		$args['interval']            = $request['interval'];
-		$args['page']                = $request['page'];
-		$args['per_page']            = $request['per_page'];
-		$args['orderby']             = $request['orderby'];
-		$args['order']               = $request['order'];
-		$args['fields']              = $request['fields'];
-		$args['match']               = $request['match'];
-		$args['status_is']           = (array) $request['status_is'];
-		$args['status_is_not']       = (array) $request['status_is_not'];
-		$args['product_includes']    = (array) $request['product_includes'];
-		$args['product_excludes']    = (array) $request['product_excludes'];
-		$args['variation_includes']  = (array) $request['variation_includes'];
-		$args['variation_excludes']  = (array) $request['variation_excludes'];
-		$args['coupon_includes']     = (array) $request['coupon_includes'];
-		$args['coupon_excludes']     = (array) $request['coupon_excludes'];
-		$args['tax_rate_includes']   = (array) $request['tax_rate_includes'];
-		$args['tax_rate_excludes']   = (array) $request['tax_rate_excludes'];
-		$args['customer_type']       = $request['customer_type'];
-		$args['refunds']             = $request['refunds'];
-		$args['attribute_is']        = (array) $request['attribute_is'];
-		$args['attribute_is_not']    = (array) $request['attribute_is_not'];
-		$args['category_includes']   = (array) $request['categories'];
-		$args['segmentby']           = $request['segmentby'];
-		$args['force_cache_refresh'] = $request['force_cache_refresh'];
+		$args                          = array();
+		$args['before']                = $request['before'];
+		$args['after']                 = $request['after'];
+		$args['interval']              = $request['interval'];
+		$args['page']                  = $request['page'];
+		$args['per_page']              = $request['per_page'];
+		$args['orderby']               = $request['orderby'];
+		$args['order']                 = $request['order'];
+		$args['fields']                = $request['fields'];
+		$args['match']                 = $request['match'];
+		$args['status_is']             = (array) $request['status_is'];
+		$args['status_is_not']         = (array) $request['status_is_not'];
+		$args['product_includes']      = (array) $request['product_includes'];
+		$args['product_excludes']      = (array) $request['product_excludes'];
+		$args['variation_includes']    = (array) $request['variation_includes'];
+		$args['variation_excludes']    = (array) $request['variation_excludes'];
+		$args['coupon_includes']       = (array) $request['coupon_includes'];
+		$args['coupon_excludes']       = (array) $request['coupon_excludes'];
+		$args['tax_rate_includes']     = (array) $request['tax_rate_includes'];
+		$args['tax_rate_excludes']     = (array) $request['tax_rate_excludes'];
+		$args['customer_type']         = $request['customer_type'];
+		$args['payment_method_is']     = (array) $request['payment_method_is'];
+		$args['payment_method_is_not'] = (array) $request['payment_method_is_not'];
+		$args['refunds']               = $request['refunds'];
+		$args['attribute_is']          = (array) $request['attribute_is'];
+		$args['attribute_is_not']      = (array) $request['attribute_is_not'];
+		$args['category_includes']     = (array) $request['categories'];
+		$args['segmentby']             = $request['segmentby'];
+		$args['force_cache_refresh']   = $request['force_cache_refresh'];

 		// For backwards compatibility, `customer` is aliased to `customer_type`.
 		if ( empty( $request['customer_type'] ) && ! empty( $request['customer'] ) ) {
@@ -261,7 +263,7 @@ class Controller extends GenericStatsController {
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
 		// Split assignments for PHPCS complaining on aligned.
-		$params['variation_includes'] = array(
+		$params['variation_includes']    = array(
 			'description'       => __( 'Limit result set to items that have the specified variation(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -271,7 +273,7 @@ class Controller extends GenericStatsController {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['variation_excludes'] = array(
+		$params['variation_excludes']    = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified variation(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -281,7 +283,7 @@ class Controller extends GenericStatsController {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['coupon_includes']    = array(
+		$params['coupon_includes']       = array(
 			'description'       => __( 'Limit result set to items that have the specified coupon(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -290,7 +292,7 @@ class Controller extends GenericStatsController {
 			'default'           => array(),
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['coupon_excludes']    = array(
+		$params['coupon_excludes']       = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified coupon(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -299,7 +301,7 @@ class Controller extends GenericStatsController {
 			'default'           => array(),
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['tax_rate_includes']  = array(
+		$params['tax_rate_includes']     = array(
 			'description'       => __( 'Limit result set to items that have the specified tax rate(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -309,7 +311,7 @@ class Controller extends GenericStatsController {
 			'sanitize_callback' => 'wp_parse_id_list',
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['tax_rate_excludes']  = array(
+		$params['tax_rate_excludes']     = array(
 			'description'       => __( 'Limit result set to items that don\'t have the specified tax rate(s) assigned.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -319,7 +321,7 @@ class Controller extends GenericStatsController {
 			'validate_callback' => 'rest_validate_request_arg',
 			'sanitize_callback' => 'wp_parse_id_list',
 		);
-		$params['customer']           = array(
+		$params['customer']              = array(
 			'description'       => __( 'Alias for customer_type (deprecated).', 'woocommerce' ),
 			'type'              => 'string',
 			'enum'              => array(
@@ -328,7 +330,27 @@ class Controller extends GenericStatsController {
 			),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['customer_type']      = array(
+		$params['payment_method_is']     = array(
+			'description'       => __( 'Limit result set to items that were paid with the specified payment gateway(s).', 'woocommerce' ),
+			'type'              => 'array',
+			'default'           => array(),
+			'sanitize_callback' => 'wp_parse_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'string',
+			),
+		);
+		$params['payment_method_is_not'] = array(
+			'description'       => __( 'Limit result set to items that were not paid with the specified payment gateway(s).', 'woocommerce' ),
+			'type'              => 'array',
+			'default'           => array(),
+			'sanitize_callback' => 'wp_parse_list',
+			'validate_callback' => 'rest_validate_request_arg',
+			'items'             => array(
+				'type' => 'string',
+			),
+		);
+		$params['customer_type']         = array(
 			'description'       => __( 'Limit result set to orders that have the specified customer_type', 'woocommerce' ),
 			'type'              => 'string',
 			'enum'              => array(
@@ -337,7 +359,7 @@ class Controller extends GenericStatsController {
 			),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['refunds']            = array(
+		$params['refunds']               = array(
 			'description'       => __( 'Limit result set to specific types of refunds.', 'woocommerce' ),
 			'type'              => 'string',
 			'default'           => '',
@@ -350,7 +372,7 @@ class Controller extends GenericStatsController {
 			),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['attribute_is']       = array(
+		$params['attribute_is']          = array(
 			'description'       => __( 'Limit result set to orders that include products with the specified attributes.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -359,7 +381,7 @@ class Controller extends GenericStatsController {
 			'default'           => array(),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['attribute_is_not']   = array(
+		$params['attribute_is_not']      = array(
 			'description'       => __( 'Limit result set to orders that don\'t include products with the specified attributes.', 'woocommerce' ),
 			'type'              => 'array',
 			'items'             => array(
@@ -368,7 +390,7 @@ class Controller extends GenericStatsController {
 			'default'           => array(),
 			'validate_callback' => 'rest_validate_request_arg',
 		);
-		$params['segmentby']          = array(
+		$params['segmentby']             = array(
 			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
 			'type'              => 'string',
 			'enum'              => array(
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 03f9e9838ba..22be771b8b2 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Orders/Stats/DataStore.php
@@ -264,6 +264,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		}

 		$where_filters[] = $this->get_customer_subquery( $query_args );
+		$where_filters[] = $this->get_payment_method_subquery( $query_args );
 		$refund_subquery = $this->get_refund_subquery( $query_args );
 		$from_clause    .= $refund_subquery['from_clause'];
 		if ( $refund_subquery['where_clause'] ) {
@@ -303,20 +304,22 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$defaults = array_merge(
 			parent::get_default_query_vars(),
 			array(
-				'interval'          => 'week',
-				'segmentby'         => '',
-
-				'match'             => 'all',
-				'status_is'         => array(),
-				'status_is_not'     => array(),
-				'product_includes'  => array(),
-				'product_excludes'  => array(),
-				'coupon_includes'   => array(),
-				'coupon_excludes'   => array(),
-				'tax_rate_includes' => array(),
-				'tax_rate_excludes' => array(),
-				'customer_type'     => '',
-				'category_includes' => array(),
+				'interval'              => 'week',
+				'segmentby'             => '',
+
+				'match'                 => 'all',
+				'status_is'             => array(),
+				'status_is_not'         => array(),
+				'product_includes'      => array(),
+				'product_excludes'      => array(),
+				'coupon_includes'       => array(),
+				'coupon_excludes'       => array(),
+				'tax_rate_includes'     => array(),
+				'tax_rate_excludes'     => array(),
+				'customer_type'         => '',
+				'payment_method_is'     => array(),
+				'payment_method_is_not' => array(),
+				'category_includes'     => array(),
 			)
 		);

diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php
index 27627531f62..c6b1cd22123 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings.php
@@ -84,7 +84,8 @@ class Settings {
 	}

 	/**
-	 * Get the titles of the installed payment gateways, for the payment method column of the Orders report.
+	 * Get the payment gateways for the Orders report payment method filter and column: the installed
+	 * ones by title, then any other gateway the stored orders were paid with, by id.
 	 *
 	 * @return array Payment gateway titles, keyed by gateway id.
 	 */
@@ -94,6 +95,10 @@ class Settings {
 			$titles[ $gateway->id ] = $gateway->get_method_title();
 		}

+		// A gateway that is no longer installed still has orders, so it stays in the filter under its id.
+		$stored  = OrdersDataStore::get_all_payment_methods();
+		$titles += array_combine( $stored, $stored );
+
 		return $titles;
 	}

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 ecf3856cf7e..6e54eeba09a 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -7,6 +7,7 @@

 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
 use Automattic\WooCommerce\Admin\Notes\Note;
 use Automattic\WooCommerce\Admin\Notes\Notes;
@@ -1174,7 +1175,7 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Migration backfills the payment method on order and refund rows, then clears its cursor and the report cache.
+	 * @testdox Migration backfills the payment method on order and refund rows, then clears its cursor, the report cache and the payment method filter cache.
 	 */
 	public function test_wc_update_1130_backfill_order_stats_payment_method(): void {
 		global $wpdb;
@@ -1212,6 +1213,10 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		$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.' );

+		// A wc-admin page load before the backfill ends caches the payment method filter without the gateway.
+		wp_cache_delete( OrdersDataStore::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, 'woocommerce_analytics' );
+		$this->assertSame( array(), OrdersDataStore::get_all_payment_methods() );
+
 		$cache_key        = 'wc_update_1130_payment_method_report';
 		$version_key      = ReportsCache::VERSION_OPTION . '-transient-version';
 		$original_version = get_transient( $version_key );
@@ -1241,6 +1246,7 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		$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.' );
+		$this->assertSame( array( 'cheque' ), OrdersDataStore::get_all_payment_methods(), 'The payment method filter cached before the backfill ended is cleared on completion.' );
 	}

 	/**
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
index e46685dbf2b..a57a86ed3bf 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/PaymentMethodTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Orders/PaymentMethodTest.php
@@ -3,7 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Orders;

+use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Query as OrdersQuery;
 use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\Query as OrdersStatsQuery;
 use Automattic\WooCommerce\Enums\OrderStatus;
 use WC_Helper_Order;
 use WC_REST_Unit_Test_Case;
@@ -105,6 +108,41 @@ class PaymentMethodTest extends WC_REST_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox The synced payment methods list each gateway once, and leave out a refund's repeat and an order without a gateway.
+	 */
+	public function test_get_all_payment_methods_lists_each_synced_gateway_once(): void {
+		$refund = wc_create_refund(
+			array(
+				'order_id' => $this->orders['bacs']->get_id(),
+				'amount'   => 10.00,
+			)
+		);
+		OrdersStatsDataStore::sync_order( $refund->get_id() );
+		$order = WC_Helper_Order::create_order();
+		$order->set_payment_method( '' );
+		$order->save();
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+
+		wp_cache_delete( OrdersDataStore::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, 'woocommerce_analytics' );
+
+		$this->assertSame( array( 'bacs', 'cheque', 'cod' ), OrdersDataStore::get_all_payment_methods() );
+	}
+
+	/**
+	 * @testdox Syncing an order paid with a new gateway adds it to the cached payment methods.
+	 */
+	public function test_sync_adds_a_new_gateway_to_the_cached_payment_methods(): void {
+		OrdersDataStore::get_all_payment_methods();
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_payment_method( 'a_new_gateway' );
+		$order->save();
+		OrdersStatsDataStore::sync_order( $order->get_id() );
+
+		$this->assertContains( 'a_new_gateway', wp_cache_get( OrdersDataStore::ORDERS_PAYMENT_METHODS_ALL_CACHE_KEY, 'woocommerce_analytics' ) );
+	}
+
 	/**
 	 * @testdox Syncing an order and its refund before the update adds the payment method column still writes their rows.
 	 */
@@ -154,6 +192,120 @@ class PaymentMethodTest extends WC_REST_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox payment_method_is limits the Orders report to the named gateways.
+	 */
+	public function test_payment_method_is_limits_the_report(): void {
+		$order_ids = $this->get_report_order_ids( array( 'payment_method_is' => array( 'bacs', 'cod' ) ) );
+
+		$this->assertEqualsCanonicalizing(
+			array( $this->orders['bacs']->get_id(), $this->orders['cod']->get_id() ),
+			$order_ids
+		);
+	}
+
+	/**
+	 * @testdox payment_method_is_not drops the named gateways from the Orders report.
+	 */
+	public function test_payment_method_is_not_drops_the_named_gateways(): void {
+		$order_ids = $this->get_report_order_ids( array( 'payment_method_is_not' => array( 'bacs' ) ) );
+
+		$this->assertNotContains( $this->orders['bacs']->get_id(), $order_ids );
+		$this->assertContains( $this->orders['cheque']->get_id(), $order_ids );
+		$this->assertContains( $this->orders['cod']->get_id(), $order_ids );
+	}
+
+	/**
+	 * @testdox payment_method_is_not keeps orders that carry no gateway, which are not the excluded one either.
+	 */
+	public function test_payment_method_is_not_keeps_orders_without_a_gateway(): void {
+		global $wpdb;
+
+		$wpdb->update(
+			$wpdb->prefix . 'wc_order_stats',
+			array( 'payment_method' => null ),
+			array( 'order_id' => $this->orders['cod']->get_id() ),
+			array( '%s' ),
+			array( '%d' )
+		);
+
+		$order_ids = $this->get_report_order_ids( array( 'payment_method_is_not' => array( 'bacs' ) ) );
+
+		$this->assertContains( $this->orders['cod']->get_id(), $order_ids );
+	}
+
+	/**
+	 * @testdox The include and exclude rules narrow together under match=any, so the table and the totals agree.
+	 */
+	public function test_payment_method_rules_narrow_together_under_match_any(): void {
+		$args = array(
+			'payment_method_is'     => array( 'bacs' ),
+			'payment_method_is_not' => array( 'cheque' ),
+			'match'                 => 'any',
+		);
+
+		$this->assertSame( array( $this->orders['bacs']->get_id() ), $this->get_report_order_ids( $args ) );
+		$this->assertSame( 1, $this->get_stats_orders_count( $args ) );
+	}
+
+	/**
+	 * @testdox The stats report totals honour the payment gateway filter, so they match the table.
+	 */
+	public function test_stats_totals_honour_the_payment_method_filter(): void {
+		$unfiltered = $this->get_stats_orders_count( array() );
+		$filtered   = $this->get_stats_orders_count( array( 'payment_method_is' => array( 'bacs' ) ) );
+
+		$this->assertSame( 3, $unfiltered );
+		$this->assertSame( 1, $filtered );
+	}
+
+	/**
+	 * @testdox The Orders report endpoint applies both payment gateway rules and returns the gateway of each row.
+	 */
+	public function test_orders_endpoint_applies_the_payment_method_rules(): void {
+		$rows = $this->get_rest_report( '/wc-analytics/reports/orders' );
+
+		$this->assertSame( array( $this->orders['bacs']->get_id() ), wp_list_pluck( $rows, 'order_id' ) );
+		$this->assertSame( 'bacs', $rows[0]['payment_method'] );
+	}
+
+	/**
+	 * @testdox The Orders stats endpoint applies both payment gateway rules to its totals.
+	 */
+	public function test_orders_stats_endpoint_applies_the_payment_method_rules(): void {
+		$report = $this->get_rest_report( '/wc-analytics/reports/orders/stats' );
+
+		$this->assertSame( 1, $report['totals']['orders_count'] );
+	}
+
+	/**
+	 * Request a report endpoint as an administrator, with rules that leave only the bacs order.
+	 *
+	 * @param string $endpoint REST route of the report.
+	 * @return array
+	 */
+	private function get_rest_report( string $endpoint ): array {
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+		$request = new WP_REST_Request( 'GET', $endpoint );
+		$request->set_query_params(
+			array_merge(
+				$this->get_period_args(),
+				// Comma separated, as the client sends them, so a rule the endpoint does not parse matches no gateway.
+				// The cheque order is in neither list, so the result changes when either rule is not applied.
+				array(
+					'payment_method_is'     => 'bacs,cod',
+					'payment_method_is_not' => 'cod,paypal',
+				)
+			)
+		);
+		$response = rest_do_request( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+
+		return $response->get_data();
+	}
+
 	/**
 	 * Read the payment method stored for an order in the stats table.
 	 *
@@ -171,6 +323,30 @@ class PaymentMethodTest extends WC_REST_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Run the Orders report and return the order ids it holds.
+	 *
+	 * @param array $args Query arguments on top of the reporting period.
+	 * @return array
+	 */
+	private function get_report_order_ids( array $args ): array {
+		$query = new OrdersQuery( array_merge( $this->get_period_args(), $args ) );
+
+		return wp_list_pluck( $query->get_data()->data, 'order_id' );
+	}
+
+	/**
+	 * Run the Orders stats report and return its order count.
+	 *
+	 * @param array $args Query arguments on top of the reporting period.
+	 * @return int
+	 */
+	private function get_stats_orders_count( array $args ): int {
+		$query = new OrdersStatsQuery( array_merge( $this->get_period_args(), $args ) );
+
+		return (int) $query->get_data()->totals->orders_count;
+	}
+
 	/**
 	 * The reporting period and cache settings shared by every query in this test.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
index acce18d7ed0..c04b69f3571 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SettingsTest.php
@@ -3,7 +3,9 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\Admin;

+use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
 use Automattic\WooCommerce\Internal\Admin\Settings;
+use WC_Helper_Order;
 use WC_REST_Setting_Options_Controller;
 use WC_Unit_Test_Case;
 use WP_REST_Request;
@@ -334,21 +336,30 @@ class SettingsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox The preloaded component settings list the installed payment gateways by title.
+	 * @testdox The preloaded component settings list the installed payment gateways by title, then any other gateway the stored orders were paid with, by id.
 	 */
-	public function test_component_settings_expose_payment_gateway_titles(): void {
+	public function test_component_settings_include_payment_gateways_that_are_no_longer_installed(): void {
+		foreach ( array( 'bacs', 'a_removed_gateway', '' ) as $payment_method ) {
+			$order = WC_Helper_Order::create_order();
+			$order->set_payment_method( $payment_method );
+			$order->save();
+			OrdersScheduler::import( $order->get_id() );
+		}
 		// 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() );
+			$gateways = $this->sut->add_component_settings( array() )['paymentGateways'];
 		} 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.' );
+		$installed = array_keys( WC()->payment_gateways()->payment_gateways() );
+		$this->assertSame( $installed, array_slice( array_keys( $gateways ), 0, count( $installed ) ), 'The installed gateways should stay first, in their order.' );
+		$this->assertSame( 'Direct bank transfer', $gateways['bacs'], 'An installed gateway should keep its title.' );
+		$this->assertSame( 'a_removed_gateway', $gateways['a_removed_gateway'], 'A gateway that is gone should be listed under its id.' );
+		$this->assertArrayNotHasKey( '', $gateways, 'An order placed without a gateway should not add an empty option.' );
 	}

 	/**