Commit 0f24c4864e1 for woocommerce

commit 0f24c4864e18c3ad0ec6aa97aa330ed4c45d9e8f
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Tue Sep 22 11:28:10 2026 +0300

    Add a location filter to the Analytics Taxes report (#68839)

diff --git a/plugins/woocommerce/changelog/43842-analytics-taxes-location-filter b/plugins/woocommerce/changelog/43842-analytics-taxes-location-filter
new file mode 100644
index 00000000000..373673bafd6
--- /dev/null
+++ b/plugins/woocommerce/changelog/43842-analytics-taxes-location-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a location filter to the Analytics Taxes report, narrowing the report, its summary and its export to a country or a state.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/taxes/config.js b/plugins/woocommerce/client/admin/client/analytics/report/taxes/config.js
index b117da19043..4bafcdeec46 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/taxes/config.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/taxes/config.js
@@ -12,6 +12,7 @@ import { dispatch } from '@wordpress/data';
  */
 import { getRequestByIdString } from '../../../lib/async-requests';
 import { getTaxCode } from './utils';
+import { getLocationLabels, locationsAutocompleter } from './locations';

 const TAXES_REPORT_CHARTS_FILTER = 'woocommerce_admin_taxes_report_charts';
 const TAXES_REPORT_FILTERS_FILTER = 'woocommerce_admin_taxes_report_filters';
@@ -72,7 +73,40 @@ export const charts = applyFilters( TAXES_REPORT_CHARTS_FILTER, [
 export const advancedFilters = applyFilters(
 	TAXES_REPORT_ADVANCED_FILTERS_FILTER,
 	{
-		filters: {},
+		filters: {
+			location: {
+				labels: {
+					add: __( 'Location', 'woocommerce' ),
+					placeholder: __( 'Search', 'woocommerce' ),
+					remove: __( 'Remove location filter', 'woocommerce' ),
+					rule: __( 'Select a location filter match', 'woocommerce' ),
+					/* translators: A sentence describing a Location filter. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ */
+					title: __(
+						'<title>Location</title> <rule/> <filter/>',
+						'woocommerce'
+					),
+					filter: __( 'Select location', 'woocommerce' ),
+				},
+				rules: [
+					{
+						value: 'includes',
+						/* translators: Sentence fragment, logical, "Includes" refers to tax codes of a given location or locations. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
+						label: _x( 'Includes', 'locations', 'woocommerce' ),
+					},
+					{
+						value: 'excludes',
+						/* translators: Sentence fragment, logical, "Excludes" refers to tax codes outside a given location or locations. Screenshot for context: https://cloudup.com/cSsUY9VeCVJ */
+						label: _x( 'Excludes', 'locations', 'woocommerce' ),
+					},
+				],
+				input: {
+					component: 'Search',
+					type: 'custom',
+					autocompleter: locationsAutocompleter,
+					getLabels: getLocationLabels,
+				},
+			},
+		},
 		title: _x(
 			'Taxes match <select/> filters',
 			'A sentence describing filters for Taxes. See screen shot for context: https://cloudup.com/cSsUY9VeCVJ',
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/taxes/locations.js b/plugins/woocommerce/client/admin/client/analytics/report/taxes/locations.js
new file mode 100644
index 00000000000..618fc24aed5
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/report/taxes/locations.js
@@ -0,0 +1,159 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { decodeEntities } from '@wordpress/html-entities';
+import { resolveSelect } from '@wordpress/data';
+import { COUNTRIES_STORE_NAME } from '@woocommerce/data';
+import { Flag } from '@woocommerce/components';
+
+// Cache the locations to avoid rebuilding a few thousand of them on every keystroke.
+let allLocations = null;
+
+/**
+ * The locations a tax code can belong to: every country, plus every state of that country.
+ *
+ * Keys are the form the `location_includes` and `location_excludes` report parameters read,
+ * a country code (`GB`) or a country and state pair (`US:CA`).
+ *
+ * @return {Promise<Array<{key: string, label: string, country: string, keywords: string[]}>>} Locations.
+ */
+async function getLocations() {
+	if ( allLocations ) {
+		return allLocations;
+	}
+
+	const countries =
+		await resolveSelect( COUNTRIES_STORE_NAME ).getCountries();
+
+	const locations = ( countries || [] ).reduce( ( carry, country ) => {
+		const countryName = decodeEntities( country.name );
+
+		carry.push( {
+			key: country.code,
+			label: countryName,
+			country: country.code,
+			keywords: [ country.code, countryName ],
+		} );
+
+		( country.states || [] ).forEach( ( state ) => {
+			const stateName = decodeEntities( state.name );
+			const key = `${ country.code }:${ state.code }`;
+
+			carry.push( {
+				key,
+				// The country code rather than its name: the filter input is narrow, and a
+				// long label wraps a character at a time in the results list.
+				label: sprintf(
+					/* translators: 1: state name, 2: country code. Example: California (US) */
+					__( '%1$s (%2$s)', 'woocommerce' ),
+					stateName,
+					country.code
+				),
+				country: country.code,
+				keywords: [ key, stateName ],
+			} );
+		} );
+
+		return carry;
+	}, [] );
+
+	if ( locations.length ) {
+		allLocations = locations;
+	}
+
+	return locations;
+}
+
+/**
+ * Wrap the part of a label the search matched, so the dropdown highlights it.
+ *
+ * @param {string} label Location label.
+ * @param {string} query Search query.
+ * @return {Object} Label split around the match.
+ */
+function highlightMatch( label, query ) {
+	const start = query
+		? label.toLowerCase().indexOf( query.toLowerCase() )
+		: -1;
+
+	if ( start === -1 ) {
+		return { before: label, match: '', after: '' };
+	}
+
+	return {
+		before: label.substring( 0, start ),
+		match: label.substring( start, start + query.length ),
+		after: label.substring( start + query.length ),
+	};
+}
+
+/**
+ * Autocompleter matching countries and their states.
+ */
+export const locationsAutocompleter = {
+	name: 'locations',
+	// Every result carries a flag beside a name, so it takes the country result styles.
+	className: 'woocommerce-search__country-result',
+	isDebounced: true,
+	options: getLocations,
+	getOptionIdentifier( location ) {
+		return location.key;
+	},
+	getOptionKeywords( location ) {
+		return location.keywords;
+	},
+	getSearchExpression( query ) {
+		return '^' + query;
+	},
+	getOptionLabel( location, query ) {
+		const { before, match, after } = highlightMatch(
+			location.label,
+			query
+		);
+
+		return (
+			<>
+				<Flag
+					key="thumbnail"
+					className="woocommerce-search__result-thumbnail"
+					code={ location.country }
+					size={ 18 }
+					hideFromScreenReader
+				/>
+				<span
+					key="name"
+					className="woocommerce-search__result-name"
+					aria-label={ location.label }
+				>
+					{ before }
+					<strong className="components-form-token-field__suggestion-match">
+						{ match }
+					</strong>
+					{ after }
+				</span>
+			</>
+		);
+	},
+	getOptionCompletion( location ) {
+		return {
+			key: location.key,
+			label: location.label,
+		};
+	},
+};
+
+/**
+ * Labels of the locations a filter value holds, for the tags of a filter restored from the URL.
+ *
+ * @param {string} value Comma separated list of location keys.
+ * @return {Promise<Array<{key: string, label: string}>>} Labels.
+ */
+export async function getLocationLabels( value ) {
+	const keys = value.split( ',' ).filter( Boolean );
+	const locations = await getLocations();
+
+	return locations
+		.filter( ( location ) => keys.includes( location.key ) )
+		.map( ( { key, label } ) => ( { key, label } ) );
+}
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/taxes/test/locations.test.js b/plugins/woocommerce/client/admin/client/analytics/report/taxes/test/locations.test.js
new file mode 100644
index 00000000000..33b27397b0e
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/report/taxes/test/locations.test.js
@@ -0,0 +1,78 @@
+/**
+ * External dependencies
+ */
+import { resolveSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { getLocationLabels, locationsAutocompleter } from '../locations';
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	resolveSelect: jest.fn(),
+} ) );
+
+const countries = [
+	{ code: 'DE', name: 'Germany', states: [] },
+	{
+		code: 'US',
+		name: 'United States (US)',
+		states: [
+			{ code: 'CA', name: 'California' },
+			{ code: 'NY', name: 'New York' },
+		],
+	},
+];
+
+// The module builds the list once and holds on to it, so every test here reads the same
+// countries. A test needing different ones has to run in its own module registry.
+const getCountries = jest.fn( () => Promise.resolve( countries ) );
+
+describe( 'Taxes report locations', () => {
+	beforeEach( () => {
+		resolveSelect.mockReturnValue( { getCountries } );
+	} );
+
+	it( 'offers every country and every state of that country', async () => {
+		const options = await locationsAutocompleter.options();
+
+		expect( options.map( ( option ) => option.key ) ).toEqual( [
+			'DE',
+			'US',
+			'US:CA',
+			'US:NY',
+		] );
+	} );
+
+	it( 'names a state alongside its country code', async () => {
+		const options = await locationsAutocompleter.options();
+		const california = options.find( ( option ) => option.key === 'US:CA' );
+
+		expect( california.label ).toBe( 'California (US)' );
+	} );
+
+	it( 'matches a state by its own name and by its country code', async () => {
+		const options = await locationsAutocompleter.options();
+		const california = options.find( ( option ) => option.key === 'US:CA' );
+
+		expect(
+			locationsAutocompleter.getOptionKeywords( california )
+		).toEqual( [ 'US:CA', 'California' ] );
+	} );
+
+	it( 'reads back the labels of a filter restored from the URL', async () => {
+		expect( await getLocationLabels( 'US:CA,DE' ) ).toEqual( [
+			{ key: 'DE', label: 'Germany' },
+			{ key: 'US:CA', label: 'California (US)' },
+		] );
+	} );
+
+	it( 'reads the countries once and answers the rest from the list it built', async () => {
+		await locationsAutocompleter.options();
+		await locationsAutocompleter.options();
+		await getLocationLabels( 'DE' );
+
+		expect( getCountries ).toHaveBeenCalledTimes( 1 );
+	} );
+} );
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
index be0bf1e4e3f..a2c8e416c70 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
@@ -63,6 +63,8 @@ class Controller extends GenericController implements ExportableInterface {
 		$args['orderby']             = $request['orderby'];
 		$args['order']               = $request['order'];
 		$args['taxes']               = $request['taxes'];
+		$args['location_includes']   = $request['location_includes'];
+		$args['location_excludes']   = $request['location_excludes'];
 		$args['force_cache_refresh'] = $request['force_cache_refresh'];

 		return $args;
@@ -223,6 +225,16 @@ class Controller extends GenericController implements ExportableInterface {
 				'type' => 'string',
 			),
 		);
+		$params['location_includes']  = array(
+			'description'       => __( 'Includes tax rates by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['location_excludes']  = array(
+			'description'       => __( 'Excludes tax rates by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);

 		return $params;
 	}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
index 7bed1a044ed..543480f06aa 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
@@ -122,6 +122,157 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		}
 	}

+	/**
+	 * SQL condition matching the tax codes of a list of locations.
+	 *
+	 * A location is a country code (`GB`) or a country and state pair (`US:CA`). A tax code is
+	 * `COUNTRY-STATE-NAME-PRIORITY`, so a location matches as a prefix of it.
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 * @since 11.3.0
+	 *
+	 * @param string $locations  Comma separated list of locations.
+	 * @param bool   $is_include True to match the locations, false to match everything else.
+	 * @return string SQL condition. An include naming no location matches nothing; an exclude
+	 *                naming none is an empty string, so it filters nothing.
+	 */
+	public static function get_location_condition( string $locations, bool $is_include ): string {
+		global $wpdb;
+
+		$column     = $wpdb->prefix . 'woocommerce_order_items.order_item_name';
+		$conditions = array();
+
+		foreach ( explode( ',', $locations ) as $location ) {
+			$parts   = explode( ':', $location, 2 );
+			$country = self::normalize_country_code( $parts[0] );
+
+			if ( '' === $country ) {
+				continue;
+			}
+
+			$state = isset( $parts[1] ) ? self::normalize_state_code( $parts[1] ) : '';
+
+			// `US:` is not `US`. Falling back to the country prefix would widen an include to
+			// every row of that country instead of narrowing it.
+			if ( isset( $parts[1] ) && '' === $state ) {
+				continue;
+			}
+
+			$prefix = '' === $state ? "{$country}-" : "{$country}-{$state}-";
+
+			$conditions[] = $wpdb->prepare(
+				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Column name cannot be prepared.
+				"{$column} LIKE %s",
+				$wpdb->esc_like( $prefix ) . '%'
+			);
+		}
+
+		if ( ! $conditions ) {
+			return $is_include ? '1 = 0' : '';
+		}
+
+		$condition = '( ' . implode( ' OR ', $conditions ) . ' )';
+
+		return $is_include ? $condition : "NOT {$condition}";
+	}
+
+	/**
+	 * Normalize a country code the way `WC_Tax` writes it.
+	 *
+	 * `WC_Tax::format_tax_rate_country()` only uppercases it.
+	 *
+	 * @param string $code Country code.
+	 * @return string
+	 */
+	private static function normalize_country_code( string $code ): string {
+		return strtoupper( trim( $code ) );
+	}
+
+	/**
+	 * Normalize a state code the way `WC_Tax` writes it.
+	 *
+	 * `WC_Tax::prepare_tax_rate()` runs a state through `sanitize_key()`, which drops the space of
+	 * a code such as `HONG KONG`. Without the same pass the filter looks for a code the store
+	 * never wrote.
+	 *
+	 * @param string $code State code.
+	 * @return string
+	 */
+	private static function normalize_state_code( string $code ): string {
+		return strtoupper( sanitize_key( $code ) );
+	}
+
+	/**
+	 * SQL condition for the location filter of a report query.
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 * @since 11.3.0
+	 *
+	 * @param array $query_args Query arguments supplied by the user.
+	 * @return string SQL condition, or an empty string when the query carries no location filter.
+	 */
+	public static function get_location_filter_condition( array $query_args ): string {
+		$conditions = array();
+
+		foreach ( array(
+			'location_includes' => true,
+			'location_excludes' => false,
+		) as $param => $is_include ) {
+			if ( empty( $query_args[ $param ] ) ) {
+				continue;
+			}
+
+			$locations = $query_args[ $param ];
+			$locations = is_array( $locations ) ? implode( ',', $locations ) : (string) $locations;
+			$condition = self::get_location_condition( $locations, $is_include );
+
+			if ( '' !== $condition ) {
+				$conditions[] = $condition;
+			}
+		}
+
+		return $conditions ? implode( ' AND ', $conditions ) : '';
+	}
+
+	/**
+	 * The location filter of a report query, as a condition on the lookup table alone.
+	 *
+	 * The tax code lives on the tax order item, which the stats queries do not join: joining it
+	 * would repeat a summed row for every line of the order sharing its rate, so an EXISTS check
+	 * reads the code instead. A row keyed by its tax line reads that one item by its primary key.
+	 * Only a legacy row at the zero default has to search the order's tax items for its rate.
+	 *
+	 * @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
+	 * @since 11.3.0
+	 *
+	 * @param array $query_args Query arguments supplied by the user.
+	 * @return string SQL condition, or an empty string when the query carries no location filter.
+	 */
+	public static function get_location_filter_subquery( array $query_args ): string {
+		global $wpdb;
+
+		$condition = self::get_location_filter_condition( $query_args );
+
+		if ( '' === $condition ) {
+			return '';
+		}
+
+		$table_name = self::get_db_table_name();
+		$items      = $wpdb->prefix . 'woocommerce_order_items';
+		$item_meta  = $wpdb->prefix . 'woocommerce_order_itemmeta';
+		$tax_type   = OrderItemType::TAX;
+
+		return "( ( {$table_name}.order_item_id > 0 AND EXISTS ( SELECT 1 FROM {$items}
+				WHERE {$items}.order_item_id = {$table_name}.order_item_id
+				AND {$condition} ) )
+			OR ( {$table_name}.order_item_id = 0 AND EXISTS ( SELECT 1 FROM {$items}
+				JOIN {$item_meta} location_rate_id ON location_rate_id.order_item_id = {$items}.order_item_id AND location_rate_id.meta_key = 'rate_id'
+				WHERE {$items}.order_id = {$table_name}.order_id
+				AND {$items}.order_item_type = '{$tax_type}'
+				AND location_rate_id.meta_value = {$table_name}.tax_rate_id
+				AND {$condition} ) ) )";
+	}
+
 	/**
 	 * Check if the wc_order_tax_lookup table has the taxable_amount column.
 	 *
@@ -242,6 +393,14 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			$this->subquery->add_sql_clause( 'where', "AND {$order_tax_lookup_table}.tax_rate_id IN ({$allowed_taxes})" );
 		}

+		// The tax order items are already joined here, so this reads their names directly rather
+		// than through the EXISTS check the stats queries need.
+		$location_filter = self::get_location_filter_condition( $query_args );
+
+		if ( '' !== $location_filter ) {
+			$this->subquery->add_sql_clause( 'where', "AND {$location_filter}" );
+		}
+
 		if ( $order_status_filter ) {
 			$this->subquery->add_sql_clause( 'where', "AND ( {$order_status_filter} )" );
 		}
@@ -256,9 +415,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 * @return array Query parameters.
 	 */
 	public function get_default_query_vars() {
-		$defaults            = parent::get_default_query_vars();
-		$defaults['orderby'] = 'tax_rate_id';
-		$defaults['taxes']   = array();
+		$defaults                      = parent::get_default_query_vars();
+		$defaults['orderby']           = 'tax_rate_id';
+		$defaults['taxes']             = array();
+		$defaults['location_includes'] = '';
+		$defaults['location_excludes'] = '';

 		return $defaults;
 	}
@@ -294,7 +455,14 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$this->add_sql_query_params( $query_args );
 		$params = $this->get_limit_params( $query_args );

-		if ( isset( $query_args['taxes'] ) && is_array( $query_args['taxes'] ) && ! empty( $query_args['taxes'] ) ) {
+		// The selected tax codes stand in for the row count, but only while nothing else can drop
+		// a row. A location filter can, so counting them would report pages that hold nothing.
+		$counts_the_selected_taxes = isset( $query_args['taxes'] )
+			&& is_array( $query_args['taxes'] )
+			&& ! empty( $query_args['taxes'] )
+			&& '' === self::get_location_filter_condition( $query_args );
+
+		if ( $counts_the_selected_taxes ) {
 			$total_results = count( $query_args['taxes'] );
 			$total_pages   = (int) ceil( $total_results / $params['per_page'] );
 		} else {
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php
index be02d4a5f5e..2ced4afc227 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Controller.php
@@ -76,6 +76,8 @@ class Controller extends GenericStatsController {
 		$args['orderby']             = $request['orderby'];
 		$args['order']               = $request['order'];
 		$args['taxes']               = (array) $request['taxes'];
+		$args['location_includes']   = $request['location_includes'];
+		$args['location_excludes']   = $request['location_excludes'];
 		$args['segmentby']           = $request['segmentby'];
 		$args['fields']              = $request['fields'];
 		$args['force_cache_refresh'] = $request['force_cache_refresh'];
@@ -185,8 +187,8 @@ class Controller extends GenericStatsController {
 	 * @return array
 	 */
 	public function get_collection_params() {
-		$params                    = parent::get_collection_params();
-		$params['orderby']['enum'] = $this->apply_custom_orderby_filters(
+		$params                      = parent::get_collection_params();
+		$params['orderby']['enum']   = $this->apply_custom_orderby_filters(
 			array(
 				'date',
 				'items_sold',
@@ -195,7 +197,7 @@ class Controller extends GenericStatsController {
 				'products_count',
 			)
 		);
-		$params['taxes']           = array(
+		$params['taxes']             = array(
 			'description'       => __( 'Limit result set to all items that have the specified term assigned in the taxes taxonomy.', 'woocommerce' ),
 			'type'              => 'array',
 			'sanitize_callback' => 'wp_parse_id_list',
@@ -204,7 +206,17 @@ class Controller extends GenericStatsController {
 				'type' => 'integer',
 			),
 		);
-		$params['segmentby']       = array(
+		$params['location_includes'] = array(
+			'description'       => __( 'Includes tax rates by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['location_excludes'] = array(
+			'description'       => __( 'Excludes tax rates by location (state, country). Provide a comma-separated list of locations. Each location can be a country code (e.g. GB) or combination of country and state (e.g. US:CA).', 'woocommerce' ),
+			'type'              => 'string',
+			'validate_callback' => 'rest_validate_request_arg',
+		);
+		$params['segmentby']         = array(
 			'description'       => __( 'Segment the response by additional constraint.', 'woocommerce' ),
 			'type'              => 'string',
 			'enum'              => array(
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php
index 1c8b8cc661c..dc164dcce2a 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php
@@ -125,6 +125,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			/* phpcs:enable */
 		}

+		$location_filter = TaxesDataStore::get_location_filter_subquery( $query_args );
+
+		if ( '' !== $location_filter ) {
+			$taxes_where_clause .= " AND {$location_filter}";
+		}
+
 		if ( $order_status_filter ) {
 			$taxes_where_clause .= " AND ( {$order_status_filter} )";
 		}
@@ -173,9 +179,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 * @return array Query parameters.
 	 */
 	public function get_default_query_vars() {
-		$defaults            = parent::get_default_query_vars();
-		$defaults['orderby'] = 'tax_rate_id';
-		$defaults['taxes']   = array();
+		$defaults                      = parent::get_default_query_vars();
+		$defaults['orderby']           = 'tax_rate_id';
+		$defaults['taxes']             = array();
+		$defaults['location_includes'] = '';
+		$defaults['location_excludes'] = '';

 		return $defaults;
 	}
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
index dbbb8be4cd2..8c7b2449d82 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
@@ -180,4 +180,16 @@ class ControllerTest extends WC_Unit_Test_Case {

 		$this->assertSame( $item, $received_item, 'Filter should receive the original report item as second argument' );
 	}
+
+	/**
+	 * @testdox The collection accepts the location filter parameters, so the report and its export can be narrowed to a region.
+	 */
+	public function test_get_collection_params_registers_the_location_filter(): void {
+		$params = $this->sut->get_collection_params();
+
+		$this->assertArrayHasKey( 'location_includes', $params, 'The report should accept an included location list.' );
+		$this->assertArrayHasKey( 'location_excludes', $params, 'The report should accept an excluded location list.' );
+		$this->assertSame( 'string', $params['location_includes']['type'], 'Locations are passed as a comma separated list.' );
+		$this->assertSame( 'string', $params['location_excludes']['type'], 'Locations are passed as a comma separated list.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
index 38f30eb211f..c0a259c975d 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
@@ -1588,4 +1588,466 @@ class DataStoreTest extends WC_Unit_Test_Case {

 		$this->assertStringContainsString( 'NOT EXISTS', DataStore::get_legacy_row_condition(), 'The check should be back once the re-key has landed.' );
 	}
+
+	/**
+	 * Tax lines of three jurisdictions, none of them backed by a row in the tax rates table,
+	 * which is the shape a rate that has since been edited or deleted leaves behind.
+	 *
+	 * @return array
+	 */
+	private function tax_lines_in_three_locations(): array {
+		return array(
+			array(
+				'code'         => 'US-CA-STATE TAX-1',
+				'label'        => 'State Tax',
+				'rate_id'      => 201,
+				'rate_percent' => 7.25,
+				'tax_total'    => 7.25,
+			),
+			array(
+				'code'         => 'US-NY-STATE TAX-1',
+				'label'        => 'State Tax',
+				'rate_id'      => 202,
+				'rate_percent' => 4.0,
+				'tax_total'    => 4.0,
+			),
+			array(
+				'code'         => 'DE-VAT-1',
+				'label'        => 'VAT',
+				'rate_id'      => 203,
+				'rate_percent' => 19.0,
+				'tax_total'    => 19.0,
+			),
+		);
+	}
+
+	/**
+	 * Run the Taxes table report over February 2023 with a location filter.
+	 *
+	 * @param array $location_args Location filter arguments.
+	 * @return array Report rows.
+	 */
+	private function taxes_report_rows_for_location( array $location_args ): array {
+		$sut  = new DataStore();
+		$data = $sut->get_data( $this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + $location_args );
+
+		return $data->data;
+	}
+
+	/**
+	 * @testdox Taxes report keeps only the rows of the filtered country.
+	 */
+	public function test_taxes_report_filters_rows_by_country(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'DE' ) );
+
+		$this->assertCount( 1, $rows, 'Only the German tax code should be reported.' );
+		$this->assertSame( 'DE', $rows[0]['country'], 'The reported row should be the one the filter names.' );
+		$this->assertSame( 19.0, $rows[0]['total_tax'], 'The row should carry its own tax amount.' );
+	}
+
+	/**
+	 * @testdox Taxes report keeps only the rows of the filtered state.
+	 */
+	public function test_taxes_report_filters_rows_by_country_and_state(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'US:CA' ) );
+
+		$this->assertCount( 1, $rows, 'Only the Californian tax code should be reported.' );
+		$this->assertSame( 'CA', $rows[0]['state'], 'The reported row should be the one the filter names.' );
+		$this->assertSame( 7.25, $rows[0]['total_tax'], 'The row should carry its own tax amount.' );
+	}
+
+	/**
+	 * @testdox Taxes report reads a filter holding several locations as any of them.
+	 */
+	public function test_taxes_report_filters_rows_by_several_locations(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'US:CA,DE' ) );
+
+		$this->assertCount( 2, $rows, 'Both named locations should be reported.' );
+		$this->assertSame( 26.25, array_sum( array_column( $rows, 'total_tax' ) ), 'The rows should add up to the tax of the named locations.' );
+	}
+
+	/**
+	 * @testdox Taxes report drops the rows of an excluded location.
+	 */
+	public function test_taxes_report_excludes_rows_by_location(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_excludes' => 'US' ) );
+
+		$this->assertCount( 1, $rows, 'Only the tax codes outside the excluded country should be reported.' );
+		$this->assertSame( 'DE', $rows[0]['country'], 'The excluded country should leave the report.' );
+	}
+
+	/**
+	 * Tax lines whose codes hold a hyphen where the country, state, name and priority meet:
+	 * `DE-BW` is a state code in `i18n/states.php`, and a rate name can hold one too.
+	 *
+	 * @return array
+	 */
+	private function tax_lines_with_hyphenated_codes(): array {
+		return array(
+			array(
+				'code'         => 'DE-DE-BW-VAT-1',
+				'label'        => 'VAT',
+				'rate_id'      => 301,
+				'rate_percent' => 19.0,
+				'tax_total'    => 19.0,
+			),
+			array(
+				'code'         => 'US-CA-CITY-TAX-2',
+				'label'        => 'City Tax',
+				'rate_id'      => 302,
+				'rate_percent' => 1.25,
+				'tax_total'    => 1.25,
+			),
+			array(
+				'code'         => 'US-NY-STATE TAX-1',
+				'label'        => 'State Tax',
+				'rate_id'      => 303,
+				'rate_percent' => 4.0,
+				'tax_total'    => 4.0,
+			),
+		);
+	}
+
+	/**
+	 * @testdox Taxes report keeps the rows of a state whose code holds a hyphen.
+	 */
+	public function test_taxes_report_filters_rows_by_a_state_code_holding_a_hyphen(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_with_hyphenated_codes(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'DE:DE-BW' ) );
+
+		$this->assertCount( 1, $rows, 'A state code holding a hyphen should still select its rows.' );
+		$this->assertSame( 19.0, $rows[0]['total_tax'], 'The reported row should be the German one.' );
+	}
+
+	/**
+	 * @testdox Taxes report keeps the rows of a state whose rate name holds a hyphen.
+	 */
+	public function test_taxes_report_filters_rows_by_state_when_the_rate_name_holds_a_hyphen(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_with_hyphenated_codes(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'US:CA' ) );
+
+		$this->assertCount( 1, $rows, 'A rate name holding a hyphen should not hide its row from its own state, and New York should stay out.' );
+		$this->assertSame( 1.25, $rows[0]['total_tax'], 'The reported row should be the Californian city tax.' );
+	}
+
+	/**
+	 * @testdox Taxes report keeps the rows of a state whose code holds a space.
+	 */
+	public function test_taxes_report_filters_rows_by_a_state_code_holding_a_space(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// `HONG KONG` and `NEW TERRITORIES` are state codes in `i18n/states.php` and the filter
+		// input offers them as they are written there, but `WC_Tax` drops the space on its way
+		// into the rate. The rates go in through `WC_Tax` so the codes read as the store writes
+		// them rather than as this test imagines them.
+		$rate_ids = array();
+		foreach ( array( 'HONG KONG', 'KOWLOON' ) as $state ) {
+			$rate_ids[ $state ] = (int) WC_Tax::_insert_tax_rate(
+				array(
+					'tax_rate_country'  => 'HK',
+					'tax_rate_state'    => $state,
+					'tax_rate'          => '5',
+					'tax_rate_name'     => 'VAT',
+					'tax_rate_priority' => 1,
+					'tax_rate_compound' => 0,
+					'tax_rate_shipping' => 1,
+					'tax_rate_order'    => 0,
+					'tax_rate_class'    => '',
+				)
+			);
+		}
+
+		$this->seed_order_with_tax_lines(
+			array(
+				array(
+					'code'         => WC_Tax::get_rate_code( $rate_ids['HONG KONG'] ),
+					'label'        => 'VAT',
+					'rate_id'      => $rate_ids['HONG KONG'],
+					'rate_percent' => 5.0,
+					'tax_total'    => 5.0,
+				),
+				array(
+					'code'         => WC_Tax::get_rate_code( $rate_ids['KOWLOON'] ),
+					'label'        => 'VAT',
+					'rate_id'      => $rate_ids['KOWLOON'],
+					'rate_percent' => 5.0,
+					'tax_total'    => 3.0,
+				),
+			),
+			'2023-02-10 10:00:00',
+			'2023-02-10 10:00:00'
+		);
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'HK:HONG KONG' ) );
+
+		$this->assertCount( 1, $rows, 'A state code holding a space should still select its rows, and Kowloon should stay out.' );
+		$this->assertSame( 5.0, $rows[0]['total_tax'], 'The reported row should be the Hong Kong Island one.' );
+	}
+
+	/**
+	 * @testdox Taxes report reports nothing when a location holds a character no tax code carries.
+	 */
+	public function test_taxes_report_does_not_rewrite_a_location_holding_an_unexpected_character(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		// `WC_Tax` leaves a country as it is, so an unreadable one has to match nothing rather
+		// than be rewritten into a country that exists. `%` and `_` are `LIKE` wildcards and have
+		// to stay literal, or a filter would answer with every location.
+		foreach ( array( 'U$S', 'US%', 'US:C_' ) as $location ) {
+			$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => $location ) );
+
+			$this->assertCount( 0, $rows, "Reading {$location} loosely would answer with another location's tax." );
+		}
+	}
+
+	/**
+	 * @testdox Taxes report reads a state the way the rate that carries it was written.
+	 */
+	public function test_taxes_report_normalizes_a_state_the_way_a_rate_is_written(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		// `WC_Tax` would have written a rate for this state as `US-CA-`, so the filter reads it
+		// the same way instead of matching a code no rate can carry.
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => 'US:C@A' ) );
+
+		$this->assertCount( 1, $rows, 'A state should be read through the same pass that wrote the rate.' );
+		$this->assertSame( 7.25, $rows[0]['total_tax'], 'The reported row should be the Californian one.' );
+	}
+
+	/**
+	 * @testdox Taxes report reports nothing when an included location holds no code to read.
+	 */
+	public function test_taxes_report_reports_nothing_for_an_included_location_holding_no_code(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => '!!!' ) );
+
+		$this->assertCount( 0, $rows, 'An include filter should never widen the report, the way the Customers report narrows to nothing.' );
+	}
+
+	/**
+	 * @testdox Taxes report reports every location when an excluded location holds no code to read.
+	 */
+	public function test_taxes_report_ignores_an_excluded_location_holding_no_code(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_excludes' => '!!!' ) );
+
+		$this->assertCount( 3, $rows, 'An exclude filter that names nothing should leave the report alone.' );
+	}
+
+	/**
+	 * @testdox Taxes report reports nothing when an included location names a state it cannot read.
+	 */
+	public function test_taxes_report_reports_nothing_for_an_included_state_holding_no_code(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		foreach ( array( 'US:', 'US:!!!' ) as $location ) {
+			$rows = $this->taxes_report_rows_for_location( array( 'location_includes' => $location ) );
+
+			$this->assertCount( 0, $rows, "An include naming a state that reads as nothing ({$location}) should not fall back to its country." );
+		}
+	}
+
+	/**
+	 * @testdox Taxes report keeps every row when an excluded location names a state it cannot read.
+	 */
+	public function test_taxes_report_ignores_an_excluded_state_holding_no_code(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$rows = $this->taxes_report_rows_for_location( array( 'location_excludes' => 'US:!!!' ) );
+
+		$this->assertCount( 3, $rows, 'An exclude naming a state that reads as nothing should not drop its country.' );
+	}
+
+	/**
+	 * @testdox Taxes report counts the rows a location filter leaves, not the tax codes asked for.
+	 */
+	public function test_taxes_report_counts_the_rows_left_by_a_location_filter(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		// Two tax codes asked for, one of them outside the filtered location. Counting the codes
+		// would report a page that holds nothing.
+		$sut  = new DataStore();
+		$data = $sut->get_data(
+			$this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + array(
+				'taxes'             => array( 201, 202 ),
+				'location_includes' => 'US:CA',
+			)
+		);
+
+		$this->assertCount( 1, $data->data, 'Only the Californian tax code should be reported.' );
+		$this->assertSame( 1, $data->total, 'The total should count the rows the filter leaves.' );
+		$this->assertSame( 1, $data->pages, 'A page beyond the filtered rows should not be offered.' );
+	}
+
+	/**
+	 * @testdox Taxes stats totals count only the tax lines of the filtered location.
+	 */
+	public function test_taxes_stats_totals_honour_the_location_filter(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$sut   = new StatsDataStore();
+		$query = $this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + array(
+			'interval'          => 'day',
+			'location_includes' => 'US:CA',
+		);
+		$data  = $sut->get_data( $query );
+
+		$this->assertSame( 7.25, $data->totals->total_tax, 'The summary should add up the filtered location alone, so it reconciles with the table.' );
+		$this->assertSame( 1, $data->totals->tax_codes, 'Only the tax code of the filtered location should be counted.' );
+		$this->assertSame( 1, $data->totals->orders_count, 'The order should be counted once.' );
+	}
+
+	/**
+	 * @testdox Taxes stats totals drop the tax lines of an excluded location.
+	 */
+	public function test_taxes_stats_totals_honour_an_excluded_location(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$sut   = new StatsDataStore();
+		$query = $this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + array(
+			'interval'          => 'day',
+			'location_excludes' => 'US',
+		);
+		$data  = $sut->get_data( $query );
+
+		$this->assertSame( 19.0, $data->totals->total_tax, 'The summary should leave out the excluded country.' );
+		$this->assertSame( 1, $data->totals->tax_codes, 'Only the tax code outside the excluded country should be counted.' );
+	}
+
+	/**
+	 * @testdox Taxes stats count a tax line once when the order holds several lines on one rate.
+	 */
+	public function test_taxes_stats_do_not_repeat_a_line_under_a_location_filter(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		// Every line carries rate id 0, so a join to the tax order items would match each lookup
+		// row against all four of them.
+		$this->seed_order_with_tax_lines( $this->tax_lines_sharing_a_rate_id(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		$sut   = new StatsDataStore();
+		$query = $this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + array(
+			'interval'          => 'day',
+			'location_includes' => 'US:CA',
+		);
+		$data  = $sut->get_data( $query );
+
+		$this->assertSame( 9.75, $data->totals->total_tax, 'The summary should match the tax the order carries, not a multiple of it.' );
+	}
+
+	/**
+	 * @testdox Taxes stats filter rows written before the lookup was keyed by tax order item.
+	 */
+	public function test_taxes_stats_filter_rows_written_before_the_grain_change(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$order = $this->seed_order_with_tax_lines( $this->tax_lines_in_three_locations(), '2023-02-10 10:00:00', '2023-02-10 10:00:00' );
+
+		// Until the rebuild runs, every row sits at the column default and names no tax line, so
+		// the filter has to find its code through the rate id the row carries.
+		$this->unmigrate_lookup_rows( $order->get_id() );
+
+		$sut   = new StatsDataStore();
+		$query = $this->all_taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59' ) + array( 'interval' => 'day' );
+
+		$included = $sut->get_data( $query + array( 'location_includes' => 'US:CA' ) );
+		$this->assertSame( 7.25, $included->totals->total_tax, 'A row waiting on the migration should still answer to its own location.' );
+
+		$excluded = $sut->get_data( $query + array( 'location_excludes' => 'US' ) );
+		$this->assertSame( 19.0, $excluded->totals->total_tax, 'Excluding a country should drop the rows waiting on the migration too.' );
+	}
+
+	/**
+	 * @testdox The country and state report columns keep the released SQL the report columns filter carries.
+	 */
+	public function test_report_columns_keep_their_released_sql(): void {
+		global $wpdb;
+
+		$columns = array();
+
+		add_filter(
+			'woocommerce_admin_report_columns',
+			function ( $report_columns, $context ) use ( &$columns ) {
+				if ( 'taxes' === $context ) {
+					$columns = $report_columns;
+				}
+
+				return $report_columns;
+			},
+			10,
+			2
+		);
+
+		new DataStore();
+
+		$this->assertSame(
+			"SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',1) as country",
+			$columns['country'],
+			'Extension callbacks inspect these strings, so the country column has to read as it was released.'
+		);
+		$this->assertSame(
+			"SUBSTRING_INDEX(SUBSTRING_INDEX({$wpdb->prefix}woocommerce_order_items.order_item_name,'-',-3), '-', 1) as state",
+			$columns['state'],
+			'Extension callbacks inspect these strings, so the state column has to read as it was released.'
+		);
+	}
 }