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