Commit 4c6cad28ff9 for woocommerce
commit 4c6cad28ff962fccfe9e7f4e281de0c4f3370268
Author: louwie17 <lourensschep@gmail.com>
Date: Wed Mar 11 11:53:49 2026 +0100
Analytics report exports: add export column/item filters and forward currency from URL query (#63618)
* Add export column and item filters to Revenue Stats, Taxes, and Variations report controllers
Adds `woocommerce_report_{type}_export_columns` and `woocommerce_report_{type}_prepare_export_item`
filters to the three report controllers that were missing them, bringing them in line with the
other seven controllers (Products, Orders, Categories, Coupons, Customers, Stock, Downloads).
This allows multicurrency plugins to extend the CSV schema with a currency column.
Fixes: https://linear.app/a8c/issue/WOOPLUG-6385
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Forward selected currency into the analytics report export job payload
Extracts a `getExportQuery` utility that builds the export request query by
taking `reportQuery` as the base and merging in any URL params declared in the
report's filters or advancedFilters config that are not already covered by the
processed query. This makes the forwarding dynamic: plugins adding a filter
param (e.g. `currency`) via `applyFilters` on the report filters config will
have that param automatically included in the export job payload without any
further changes needed here.
The `reportQuery` (built by `getReportTableQuery`) only forwards a fixed set of
fields. Params added by plugins are omitted because live API requests rely on
`$_GET` server-side. Exports run as Action Scheduler background jobs with no
HTTP context, so those params must be carried explicitly in the job payload.
Fixes: https://linear.app/a8c/issue/WOOPLUG-6385
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add tests for report export column/item filters and currency export query
PHP: adds ControllerTest for Revenue Stats, Taxes, and Variations, covering
default column sets, filter extensibility (add/remove columns), extra column
values, and filter argument passing.
JS: adds unit tests for `getExportQuery`, covering filter param forwarding,
multi-param support, blocking of undeclared URL params, no-override of
reportQuery fields, nested sub-param support, advancedFilters params, empty
string exclusion, and immutability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix lint errors
* Harden getExportQuery against malformed extension config
Guard getFilterParamKeys and getExportQuery against null/undefined
extension-supplied inputs (filters, advancedFilters, urlQuery,
reportQuery) so a bad plugin config falls back gracefully instead
of throwing and breaking the export button.
Adds a regression test covering the null/undefined case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add one additional test
* fix: exclude null values from export query extra params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix: traverse subFilters when collecting export query filter params
Refactor getFilterParamKeys into a recursive collectFilterParamKeys helper
that traverses both filters and subFilters, so params like `products` and
`variations` (defined via subFilters in the products report config) are
correctly forwarded to the export job payload.
Also add tests for subFilters param forwarding and null urlQuery value exclusion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooplug-6385-analytics-export-currency-filters b/plugins/woocommerce/changelog/wooplug-6385-analytics-export-currency-filters
new file mode 100644
index 00000000000..49fc7fc58be
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6385-analytics-export-currency-filters
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add export column and item filters to Revenue Stats, Taxes, and Variations report controllers, and forward selected currency from URL query params into the export job payload.
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js b/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
index 55a2ddaca01..2fb39ea9ca9 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-table/index.js
@@ -43,7 +43,7 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import DownloadIcon from './download-icon';
-import { extendTableData } from './utils';
+import { extendTableData, getExportQuery } from './utils';
import './style.scss';
const TABLE_FILTER = 'woocommerce_admin_report_table';
@@ -191,7 +191,8 @@ const ReportTable = ( props ) => {
};
const onClickDownload = () => {
- const { createNotice, startExport, title } = props;
+ const { createNotice, startExport, title, filters, advancedFilters } =
+ props;
const params = Object.assign( {}, query );
const { data, totalResults } = items;
let downloadType = 'browser';
@@ -211,7 +212,10 @@ const ReportTable = ( props ) => {
);
} else {
downloadType = 'email';
- startExport( endpoint, reportQuery )
+ startExport(
+ endpoint,
+ getExportQuery( reportQuery, query, filters, advancedFilters )
+ )
.then( () =>
createNotice(
'success',
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-table/test/utils.js b/plugins/woocommerce/client/admin/client/analytics/components/report-table/test/utils.js
new file mode 100644
index 00000000000..d59ffc03ac2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-table/test/utils.js
@@ -0,0 +1,172 @@
+/**
+ * Internal dependencies
+ */
+import { getExportQuery } from '../utils';
+
+describe( 'getExportQuery', () => {
+ it( 'preserves all reportQuery fields', () => {
+ const reportQuery = { orderby: 'date', order: 'desc', per_page: 25 };
+
+ const result = getExportQuery( reportQuery, {}, [], {} );
+
+ expect( result.orderby ).toBe( 'date' );
+ expect( result.order ).toBe( 'desc' );
+ expect( result.per_page ).toBe( 25 );
+ } );
+
+ it( 'forwards a filter param present in urlQuery but not in reportQuery', () => {
+ const reportQuery = { orderby: 'date', order: 'desc' };
+ const urlQuery = { currency: 'EUR' };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.currency ).toBe( 'EUR' );
+ } );
+
+ it( 'should not forward a filter param present in urlQuery but not in reportQuery', () => {
+ const reportQuery = { orderby: 'date', order: 'desc' };
+ const urlQuery = { status: 'completed' };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.currency ).not.toBeDefined();
+ expect( result.status ).not.toBeDefined();
+ } );
+
+ it( 'forwards multiple filter params dynamically', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { currency: 'CAD', region: 'NA' };
+ const filters = [
+ { param: 'currency', filters: [] },
+ { param: 'region', filters: [] },
+ ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.currency ).toBe( 'CAD' );
+ expect( result.region ).toBe( 'NA' );
+ } );
+
+ it( 'does not forward urlQuery params not declared in filters', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { currency: 'USD', path: '/analytics/revenue' };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.currency ).toBe( 'USD' );
+ expect( result ).not.toHaveProperty( 'path' );
+ } );
+
+ it( 'does not override reportQuery fields with urlQuery values', () => {
+ const reportQuery = { orderby: 'net_revenue', order: 'asc' };
+ const urlQuery = { orderby: 'date', order: 'desc', currency: 'USD' };
+ const filters = [
+ { param: 'orderby', filters: [] },
+ { param: 'currency', filters: [] },
+ ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.orderby ).toBe( 'net_revenue' );
+ expect( result.order ).toBe( 'asc' );
+ expect( result.currency ).toBe( 'USD' );
+ } );
+
+ it( 'forwards params from nested filter sub-params', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { product_id: '42' };
+ const filters = [
+ {
+ param: 'filter',
+ filters: [
+ { value: 'single', settings: { param: 'product_id' } },
+ ],
+ },
+ ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.product_id ).toBe( '42' );
+ } );
+
+ it( 'forwards params declared in advancedFilters', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { status: 'completed' };
+ const advancedFilters = { filters: { status: {} } };
+
+ const result = getExportQuery(
+ reportQuery,
+ urlQuery,
+ [],
+ advancedFilters
+ );
+
+ expect( result.status ).toBe( 'completed' );
+ } );
+
+ it( 'forwards params from subFilters settings', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { products: '42' };
+ const filters = [
+ {
+ param: 'filter',
+ filters: [
+ {
+ value: 'select_product',
+ subFilters: [
+ {
+ settings: { param: 'products' },
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result.products ).toBe( '42' );
+ } );
+
+ it( 'does not forward empty string urlQuery values', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { currency: '' };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result ).not.toHaveProperty( 'currency' );
+ } );
+
+ it( 'does not forward null urlQuery values', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { currency: null };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result ).not.toHaveProperty( 'currency' );
+ } );
+
+ it( 'returns a new object and does not mutate reportQuery', () => {
+ const reportQuery = { orderby: 'date' };
+ const urlQuery = { currency: 'GBP' };
+ const filters = [ { param: 'currency', filters: [] } ];
+
+ const result = getExportQuery( reportQuery, urlQuery, filters );
+
+ expect( result ).not.toBe( reportQuery );
+ expect( reportQuery ).not.toHaveProperty( 'currency' );
+ } );
+
+ it( 'returns an empty object when all inputs are null or undefined', () => {
+ expect( () => getExportQuery( null, null, null, null ) ).not.toThrow();
+
+ const result = getExportQuery( null, null, null, null );
+
+ expect( result ).toEqual( {} );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/analytics/components/report-table/utils.js b/plugins/woocommerce/client/admin/client/analytics/components/report-table/utils.js
index 86e0f0dae13..40f6aad3eeb 100644
--- a/plugins/woocommerce/client/admin/client/analytics/components/report-table/utils.js
+++ b/plugins/woocommerce/client/admin/client/analytics/components/report-table/utils.js
@@ -3,6 +3,94 @@
*/
import { first } from 'lodash';
+/**
+ * Recursively collects all `param` keys from a filters/subFilters config tree.
+ *
+ * @param {Array} configs Array of filter config objects to traverse.
+ * @param {Set<string>} keys Set to collect param keys into.
+ */
+function collectFilterParamKeys( configs, keys ) {
+ for ( const config of Array.isArray( configs ) ? configs : [] ) {
+ if ( ! config || typeof config !== 'object' ) {
+ continue;
+ }
+ if ( config.param ) {
+ keys.add( config.param );
+ }
+ if ( config.settings && config.settings.param ) {
+ keys.add( config.settings.param );
+ }
+ collectFilterParamKeys( config.filters, keys );
+ collectFilterParamKeys( config.subFilters, keys );
+ }
+}
+
+/**
+ * Collects all `param` keys declared across a filters config array, including
+ * any sub-params defined in nested filter and subFilter settings.
+ *
+ * @param {Array} filters Report filters config (from props.filters).
+ * @return {Set<string>} Set of URL query param keys owned by the filters.
+ */
+function getFilterParamKeys( filters = [] ) {
+ const keys = new Set();
+ collectFilterParamKeys( filters, keys );
+ return keys;
+}
+
+/**
+ * Builds the query object to pass to startExport, merging the processed report
+ * query with any URL params that belong to a known filter config but are not
+ * already present in the report query.
+ *
+ * `reportQuery` (built by `getReportTableQuery`) only forwards a fixed set of
+ * fields (orderby, order, after, before, page, per_page, plus active filter
+ * values). Params added by plugins via `applyFilters` on the report's filters
+ * config — such as `currency` — are not forwarded because live API requests
+ * rely on `$_GET` server-side. Exports run as Action Scheduler background jobs
+ * with no HTTP context, so those params must be carried explicitly in the job
+ * payload.
+ *
+ * @param {Object} reportQuery Processed query from tableData.query.
+ * @param {Object} urlQuery Raw URL query params from props.query.
+ * @param {Array} filters Report filters config from props.filters.
+ * @param {Object} advancedFilters Report advanced filters config from props.advancedFilters.
+ * @return {Object} Query object for the export request.
+ */
+export function getExportQuery(
+ reportQuery = {},
+ urlQuery = {},
+ filters = [],
+ advancedFilters = {}
+) {
+ const safeReportQuery =
+ reportQuery && typeof reportQuery === 'object' ? reportQuery : {};
+ const safeUrlQuery =
+ urlQuery && typeof urlQuery === 'object' ? urlQuery : {};
+ const filterParamKeys = getFilterParamKeys( filters );
+ const advancedFilterMap =
+ advancedFilters && typeof advancedFilters === 'object'
+ ? advancedFilters.filters || {}
+ : {};
+
+ for ( const key of Object.keys( advancedFilterMap ) ) {
+ filterParamKeys.add( key );
+ }
+
+ const extraParams = Object.fromEntries(
+ Object.entries( safeUrlQuery ).filter(
+ ( [ key, value ] ) =>
+ filterParamKeys.has( key ) &&
+ ! ( key in safeReportQuery ) &&
+ value !== undefined &&
+ value !== null &&
+ value !== ''
+ )
+ );
+
+ return { ...safeReportQuery, ...extraParams };
+}
+
export function extendTableData(
extendedStoreSelector,
props,
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php
index 097588a658e..1a173913a62 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Revenue/Stats/Controller.php
@@ -270,7 +270,7 @@ class Controller extends GenericStatsController implements ExportableInterface {
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
- return array(
+ $export_columns = array(
'date' => __( 'Date', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
'gross_sales' => __( 'Gross sales', 'woocommerce' ),
@@ -281,6 +281,15 @@ class Controller extends GenericStatsController implements ExportableInterface {
'shipping' => __( 'Shipping', 'woocommerce' ),
'total_sales' => __( 'Total sales', 'woocommerce' ),
);
+
+ /**
+ * Filter to add or remove column names from the revenue stats report for
+ * export.
+ *
+ * @since 10.7.0
+ * @param array $export_columns Key value pair of column ID and label.
+ */
+ return apply_filters( 'woocommerce_report_revenue_stats_export_columns', $export_columns );
}
/**
@@ -292,7 +301,7 @@ class Controller extends GenericStatsController implements ExportableInterface {
public function prepare_item_for_export( $item ) {
$subtotals = (array) $item['subtotals'];
- return array(
+ $export_item = array(
'date' => $item['date_start'],
'orders_count' => $subtotals['orders_count'],
'gross_sales' => self::csv_number_format( $subtotals['gross_sales'] ),
@@ -303,5 +312,15 @@ class Controller extends GenericStatsController implements ExportableInterface {
'shipping' => self::csv_number_format( $subtotals['shipping'] ),
'total_sales' => self::csv_number_format( $subtotals['total_sales'] ),
);
+
+ /**
+ * Filter to prepare extra columns in the export item for the revenue
+ * stats report.
+ *
+ * @since 10.7.0
+ * @param array $export_item Key value pair of column ID and row value.
+ * @param array $item The original report item.
+ */
+ return apply_filters( 'woocommerce_report_revenue_stats_prepare_export_item', $export_item, $item );
}
}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
index a794fee018f..26dacfef02a 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php
@@ -226,7 +226,7 @@ class Controller extends GenericController implements ExportableInterface {
* @return array Key value pair of Column ID => Label.
*/
public function get_export_columns() {
- return array(
+ $export_columns = array(
'tax_code' => __( 'Tax code', 'woocommerce' ),
'rate' => __( 'Rate', 'woocommerce' ),
'total_tax' => __( 'Total tax', 'woocommerce' ),
@@ -234,6 +234,14 @@ class Controller extends GenericController implements ExportableInterface {
'shipping_tax' => __( 'Shipping tax', 'woocommerce' ),
'orders_count' => __( 'Orders', 'woocommerce' ),
);
+
+ /**
+ * Filter to add or remove column names from the taxes report for export.
+ *
+ * @since 10.7.0
+ * @param array $export_columns Key value pair of column ID and label.
+ */
+ return apply_filters( 'woocommerce_report_taxes_export_columns', $export_columns );
}
/**
@@ -243,7 +251,7 @@ class Controller extends GenericController implements ExportableInterface {
* @return array Key value pair of Column ID => Row Value.
*/
public function prepare_item_for_export( $item ) {
- return array(
+ $export_item = array(
'tax_code' => \WC_Tax::get_rate_code(
(object) array(
'tax_rate_id' => $item['tax_rate_id'],
@@ -259,5 +267,15 @@ class Controller extends GenericController implements ExportableInterface {
'shipping_tax' => self::csv_number_format( $item['shipping_tax'] ),
'orders_count' => $item['orders_count'],
);
+
+ /**
+ * Filter to prepare extra columns in the export item for the taxes
+ * report.
+ *
+ * @since 10.7.0
+ * @param array $export_item Key value pair of column ID and row value.
+ * @param array $item The original report item.
+ */
+ return apply_filters( 'woocommerce_report_taxes_prepare_export_item', $export_item, $item );
}
}
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php
index b268f82c60e..c44aad10d5c 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Variations/Controller.php
@@ -381,7 +381,14 @@ class Controller extends GenericController implements ExportableInterface {
$export_columns['stock'] = __( 'Stock', 'woocommerce' );
}
- return $export_columns;
+ /**
+ * Filter to add or remove column names from the variations report for
+ * export.
+ *
+ * @since 10.7.0
+ * @param array $export_columns Key value pair of column ID and label.
+ */
+ return apply_filters( 'woocommerce_report_variations_export_columns', $export_columns );
}
/**
@@ -427,6 +434,14 @@ class Controller extends GenericController implements ExportableInterface {
$export_item['stock'] = $item['extended_info']['stock_quantity'];
}
- return $export_item;
+ /**
+ * Filter to prepare extra columns in the export item for the variations
+ * report.
+ *
+ * @since 10.7.0
+ * @param array $export_item Key value pair of column ID and row value.
+ * @param array $item The original report item.
+ */
+ return apply_filters( 'woocommerce_report_variations_prepare_export_item', $export_item, $item );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Revenue/Stats/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Revenue/Stats/ControllerTest.php
new file mode 100644
index 00000000000..3d901cbb3a2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Revenue/Stats/ControllerTest.php
@@ -0,0 +1,182 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Revenue\Stats;
+
+use Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Revenue Stats report export methods.
+ */
+class ControllerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Controller
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Controller();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ remove_all_filters( 'woocommerce_report_revenue_stats_export_columns' );
+ remove_all_filters( 'woocommerce_report_revenue_stats_prepare_export_item' );
+ }
+
+ /**
+ * @testdox get_export_columns returns the default column set.
+ */
+ public function test_get_export_columns_returns_defaults(): void {
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'date', $columns );
+ $this->assertArrayHasKey( 'orders_count', $columns );
+ $this->assertArrayHasKey( 'gross_sales', $columns );
+ $this->assertArrayHasKey( 'refunds', $columns );
+ $this->assertArrayHasKey( 'coupons', $columns );
+ $this->assertArrayHasKey( 'net_revenue', $columns );
+ $this->assertArrayHasKey( 'taxes', $columns );
+ $this->assertArrayHasKey( 'shipping', $columns );
+ $this->assertArrayHasKey( 'total_sales', $columns );
+ }
+
+ /**
+ * @testdox get_export_columns allows adding a column via filter.
+ */
+ public function test_get_export_columns_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_revenue_stats_export_columns',
+ function ( $columns ) {
+ $columns['currency'] = 'Currency';
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'currency', $columns, 'Filter should be able to add a currency column' );
+ }
+
+ /**
+ * @testdox get_export_columns allows removing a column via filter.
+ */
+ public function test_get_export_columns_filter_can_remove_column(): void {
+ add_filter(
+ 'woocommerce_report_revenue_stats_export_columns',
+ function ( $columns ) {
+ unset( $columns['coupons'] );
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayNotHasKey( 'coupons', $columns, 'Filter should be able to remove a column' );
+ }
+
+ /**
+ * @testdox prepare_item_for_export returns the default export row.
+ */
+ public function test_prepare_item_for_export_returns_defaults(): void {
+ $item = array(
+ 'date_start' => '2024-01-01',
+ 'subtotals' => array(
+ 'orders_count' => 5,
+ 'gross_sales' => 100.00,
+ 'refunds' => 10.00,
+ 'coupons' => 5.00,
+ 'net_revenue' => 85.00,
+ 'taxes' => 8.50,
+ 'shipping' => 10.00,
+ 'total_sales' => 95.00,
+ ),
+ );
+
+ $export_item = $this->sut->prepare_item_for_export( $item );
+
+ $this->assertSame( '2024-01-01', $export_item['date'] );
+ $this->assertSame( 5, $export_item['orders_count'] );
+ $this->assertSame( '100.00', $export_item['gross_sales'] );
+ $this->assertSame( '85.00', $export_item['net_revenue'] );
+ }
+
+ /**
+ * @testdox prepare_item_for_export allows adding extra columns via filter.
+ */
+ public function test_prepare_item_for_export_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_revenue_stats_prepare_export_item',
+ function ( $export_item ) {
+ $export_item['currency'] = 'USD';
+ return $export_item;
+ },
+ 10
+ );
+
+ $item = array(
+ 'date_start' => '2024-01-01',
+ 'subtotals' => array(
+ 'orders_count' => 1,
+ 'gross_sales' => 50.00,
+ 'refunds' => 0.00,
+ 'coupons' => 0.00,
+ 'net_revenue' => 50.00,
+ 'taxes' => 5.00,
+ 'shipping' => 5.00,
+ 'total_sales' => 50.00,
+ ),
+ );
+
+ $export_item = $this->sut->prepare_item_for_export( $item );
+
+ $this->assertArrayHasKey( 'currency', $export_item, 'Filter should be able to add a currency column value' );
+ $this->assertSame( 'USD', $export_item['currency'] );
+ }
+
+ /**
+ * @testdox prepare_item_for_export passes the original item to the filter.
+ */
+ public function test_prepare_item_for_export_filter_receives_original_item(): void {
+ $received_item = null;
+
+ add_filter(
+ 'woocommerce_report_revenue_stats_prepare_export_item',
+ function ( $export_item, $item ) use ( &$received_item ) {
+ $received_item = $item;
+ return $export_item;
+ },
+ 10,
+ 2
+ );
+
+ $item = array(
+ 'date_start' => '2024-06-15',
+ 'subtotals' => array(
+ 'orders_count' => 3,
+ 'gross_sales' => 75.00,
+ 'refunds' => 0.00,
+ 'coupons' => 0.00,
+ 'net_revenue' => 75.00,
+ 'taxes' => 7.50,
+ 'shipping' => 7.50,
+ 'total_sales' => 75.00,
+ ),
+ );
+
+ $this->sut->prepare_item_for_export( $item );
+
+ $this->assertSame( $item, $received_item, 'Filter should receive the original report item as second argument' );
+ }
+}
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
new file mode 100644
index 00000000000..fb6d5aad8d5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/ControllerTest.php
@@ -0,0 +1,151 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Taxes;
+
+use Automattic\WooCommerce\Admin\API\Reports\Taxes\Controller;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Taxes report export methods.
+ */
+class ControllerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Controller
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Controller();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ remove_all_filters( 'woocommerce_report_taxes_export_columns' );
+ remove_all_filters( 'woocommerce_report_taxes_prepare_export_item' );
+ }
+
+ /**
+ * @testdox get_export_columns returns the default column set.
+ */
+ public function test_get_export_columns_returns_defaults(): void {
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'tax_code', $columns );
+ $this->assertArrayHasKey( 'rate', $columns );
+ $this->assertArrayHasKey( 'total_tax', $columns );
+ $this->assertArrayHasKey( 'order_tax', $columns );
+ $this->assertArrayHasKey( 'shipping_tax', $columns );
+ $this->assertArrayHasKey( 'orders_count', $columns );
+ }
+
+ /**
+ * @testdox get_export_columns allows adding a column via filter.
+ */
+ public function test_get_export_columns_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_taxes_export_columns',
+ function ( $columns ) {
+ $columns['currency'] = 'Currency';
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'currency', $columns, 'Filter should be able to add a currency column' );
+ }
+
+ /**
+ * @testdox get_export_columns allows removing a column via filter.
+ */
+ public function test_get_export_columns_filter_can_remove_column(): void {
+ add_filter(
+ 'woocommerce_report_taxes_export_columns',
+ function ( $columns ) {
+ unset( $columns['rate'] );
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayNotHasKey( 'rate', $columns, 'Filter should be able to remove a column' );
+ }
+
+ /**
+ * @testdox prepare_item_for_export allows adding extra columns via filter.
+ */
+ public function test_prepare_item_for_export_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_taxes_prepare_export_item',
+ function ( $export_item ) {
+ $export_item['currency'] = 'USD';
+ return $export_item;
+ },
+ 10
+ );
+
+ $item = array(
+ 'tax_rate_id' => 1,
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'name' => 'State Tax',
+ 'priority' => 1,
+ 'tax_rate' => '8.25',
+ 'total_tax' => 82.50,
+ 'order_tax' => 75.00,
+ 'shipping_tax' => 7.50,
+ 'orders_count' => 10,
+ );
+
+ $export_item = $this->sut->prepare_item_for_export( $item );
+
+ $this->assertArrayHasKey( 'currency', $export_item, 'Filter should be able to add a currency column value' );
+ $this->assertSame( 'USD', $export_item['currency'] );
+ }
+
+ /**
+ * @testdox prepare_item_for_export passes the original item to the filter.
+ */
+ public function test_prepare_item_for_export_filter_receives_original_item(): void {
+ $received_item = null;
+
+ add_filter(
+ 'woocommerce_report_taxes_prepare_export_item',
+ function ( $export_item, $item ) use ( &$received_item ) {
+ $received_item = $item;
+ return $export_item;
+ },
+ 10,
+ 2
+ );
+
+ $item = array(
+ 'tax_rate_id' => 2,
+ 'country' => 'GB',
+ 'state' => '',
+ 'name' => 'VAT',
+ 'priority' => 1,
+ 'tax_rate' => '20.00',
+ 'total_tax' => 200.00,
+ 'order_tax' => 200.00,
+ 'shipping_tax' => 0.00,
+ 'orders_count' => 5,
+ );
+
+ $this->sut->prepare_item_for_export( $item );
+
+ $this->assertSame( $item, $received_item, 'Filter should receive the original report item as second argument' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/Reports/Variations/ControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Variations/ControllerTest.php
new file mode 100644
index 00000000000..3db6f492447
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Variations/ControllerTest.php
@@ -0,0 +1,152 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Variations;
+
+use Automattic\WooCommerce\Admin\API\Reports\Variations\Controller;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Variations report export methods.
+ */
+class ControllerTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var Controller
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new Controller();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ remove_all_filters( 'woocommerce_report_variations_export_columns' );
+ remove_all_filters( 'woocommerce_report_variations_prepare_export_item' );
+ }
+
+ /**
+ * @testdox get_export_columns returns the default column set.
+ */
+ public function test_get_export_columns_returns_defaults(): void {
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'product_name', $columns );
+ $this->assertArrayHasKey( 'sku', $columns );
+ $this->assertArrayHasKey( 'items_sold', $columns );
+ $this->assertArrayHasKey( 'net_revenue', $columns );
+ $this->assertArrayHasKey( 'orders_count', $columns );
+ }
+
+ /**
+ * @testdox get_export_columns allows adding a column via filter.
+ */
+ public function test_get_export_columns_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_variations_export_columns',
+ function ( $columns ) {
+ $columns['currency'] = 'Currency';
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayHasKey( 'currency', $columns, 'Filter should be able to add a currency column' );
+ }
+
+ /**
+ * @testdox get_export_columns allows removing a column via filter.
+ */
+ public function test_get_export_columns_filter_can_remove_column(): void {
+ add_filter(
+ 'woocommerce_report_variations_export_columns',
+ function ( $columns ) {
+ unset( $columns['sku'] );
+ return $columns;
+ }
+ );
+
+ $columns = $this->sut->get_export_columns();
+
+ $this->assertArrayNotHasKey( 'sku', $columns, 'Filter should be able to remove a column' );
+ }
+
+ /**
+ * @testdox prepare_item_for_export allows adding extra columns via filter.
+ */
+ public function test_prepare_item_for_export_filter_can_add_column(): void {
+ add_filter(
+ 'woocommerce_report_variations_prepare_export_item',
+ function ( $export_item ) {
+ $export_item['currency'] = 'CAD';
+ return $export_item;
+ },
+ 10
+ );
+
+ $item = array(
+ 'items_sold' => 10,
+ 'net_revenue' => 250.00,
+ 'orders_count' => 8,
+ 'extended_info' => array(
+ 'name' => 'Test Product - Blue',
+ 'sku' => 'TEST-BLUE',
+ 'attributes' => array(),
+ 'stock_status' => 'instock',
+ 'stock_quantity' => 5,
+ 'manage_stock' => true,
+ ),
+ );
+
+ $export_item = $this->sut->prepare_item_for_export( $item );
+
+ $this->assertArrayHasKey( 'currency', $export_item, 'Filter should be able to add a currency column value' );
+ $this->assertSame( 'CAD', $export_item['currency'] );
+ }
+
+ /**
+ * @testdox prepare_item_for_export passes the original item to the filter.
+ */
+ public function test_prepare_item_for_export_filter_receives_original_item(): void {
+ $received_item = null;
+
+ add_filter(
+ 'woocommerce_report_variations_prepare_export_item',
+ function ( $export_item, $item ) use ( &$received_item ) {
+ $received_item = $item;
+ return $export_item;
+ },
+ 10,
+ 2
+ );
+
+ $item = array(
+ 'items_sold' => 3,
+ 'net_revenue' => 60.00,
+ 'orders_count' => 2,
+ 'extended_info' => array(
+ 'name' => 'Test Product - Red',
+ 'sku' => 'TEST-RED',
+ 'attributes' => array(),
+ 'stock_status' => 'outofstock',
+ 'stock_quantity' => 0,
+ 'manage_stock' => true,
+ ),
+ );
+
+ $this->sut->prepare_item_for_export( $item );
+
+ $this->assertSame( $item, $received_item, 'Filter should receive the original report item as second argument' );
+ }
+}