Commit b01d2055655 for woocommerce

commit b01d2055655e15da06cd2801473b07adfff8da92
Author: Jan Lysý <lysyjan@users.noreply.github.com>
Date:   Wed Aug 12 15:33:38 2026 +0200

    Fix Analytics Taxes report to reconcile with Orders and Revenue (#67553)

    * Fix Analytics Taxes report to honour configured date type

    The Taxes report bucketed its period by wc_order_tax_lookup.date_created,
    while the Orders and Revenue reports use woocommerce_date_type (default
    date_paid). The same orders landed in different months and orders with no
    paid date dropped out of Orders/Revenue but still showed in Taxes, so the
    two screens disagreed on order counts and tax totals.

    Filter the Taxes and Taxes Stats reporting period against the configured
    date column on wc_order_stats (making the stats join unconditional) so the
    Taxes report reconciles with Orders and Revenue by construction. The
    orders_count parent_id reference is qualified to wc_order_stats to stay
    unambiguous now that the join is always present. Also invalidate the report
    caches when woocommerce_date_type changes, so all report families reflect
    the new basis immediately instead of after the cache TTL.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    * Address review: sanitize date column, keep join param required

    - Route woocommerce_date_type through sanitize_date_column_name() in both Taxes
      data stores, matching Orders\DataStore (SQL-injection hardening from #67546).
    - Restore add_from_sql_params()'s second parameter to required, keeping the
      protected method signature unchanged from trunk.

    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-wooplug-6420-taxes-report-date-type b/plugins/woocommerce/changelog/fix-wooplug-6420-taxes-report-date-type
new file mode 100644
index 00000000000..1e393de1b1e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6420-taxes-report-date-type
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Analytics: report the Taxes report period by the configured date type (default date paid) so its order counts and tax totals reconcile with the Orders and Revenue reports.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
index 6af6230de97..d2a9f6752e9 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/DataStore.php
@@ -66,6 +66,20 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	protected $context = 'taxes';

+	/**
+	 * Constructor.
+	 *
+	 * Report on the date type configured in Analytics settings (default `date_paid`),
+	 * matching the Orders and Revenue reports. The reporting period is filtered against
+	 * the chosen column on `wc_order_stats` so the Taxes report reconciles with them.
+	 *
+	 * @override ReportsDataStore::__construct()
+	 */
+	public function __construct() {
+		$this->date_column_name = $this->sanitize_date_column_name( get_option( 'woocommerce_date_type' ), 'date_paid' );
+		parent::__construct();
+	}
+
 	/**
 	 * Assign report columns once full table name has been assigned.
 	 *
@@ -73,7 +87,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	protected function assign_report_columns() {
 		global $wpdb;
-		$table_name = self::get_db_table_name();
+		$table_name        = self::get_db_table_name();
+		$order_stats_table = $wpdb->prefix . 'wc_order_stats';

 		// Using wp_woocommerce_tax_rates table limits the result to only the existing tax rates and
 		// omits the historical records which differs from the purpose of wp_wc_order_tax_lookup table.
@@ -93,7 +108,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 			'total_tax'    => 'SUM(total_tax) as total_tax',
 			'order_tax'    => 'SUM(order_tax) as order_tax',
 			'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
-			'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
+			// parent_id is qualified to wc_order_stats to stay unambiguous now that the join is always present.
+			'orders_count' => "COUNT( DISTINCT ( CASE WHEN {$order_stats_table}.parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
 		);
 	}

@@ -108,15 +124,15 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 * Fills FROM clause of SQL request based on user supplied parameters.
 	 *
 	 * @param array  $query_args          Query arguments supplied by the user.
-	 * @param string $order_status_filter Order status subquery.
+	 * @param string $order_status_filter Order status subquery. Retained for signature compatibility; the wc_order_stats join is now always added.
 	 */
 	protected function add_from_sql_params( $query_args, $order_status_filter ) {
 		global $wpdb;
 		$table_name = self::get_db_table_name();

-		if ( $order_status_filter ) {
-			$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );
-		}
+		// Always join wc_order_stats: the reporting period is filtered against its
+		// configured date column (date_paid by default) and the status subquery relies on it.
+		$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}wc_order_stats ON {$table_name}.order_id = {$wpdb->prefix}wc_order_stats.order_id" );

 		$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_items ON {$table_name}.order_id = {$wpdb->prefix}woocommerce_order_items.order_id AND {$wpdb->prefix}woocommerce_order_items.order_item_type = 'tax'" );
 		$this->subquery->add_sql_clause( 'join', "JOIN {$wpdb->prefix}woocommerce_order_itemmeta itemmeta_rate_id ON itemmeta_rate_id.order_item_id = {$wpdb->prefix}woocommerce_order_items.order_item_id AND itemmeta_rate_id.meta_key = 'rate_id'" );
@@ -133,8 +149,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		global $wpdb;

 		$order_tax_lookup_table = self::get_db_table_name();
+		$order_stats_table      = $wpdb->prefix . 'wc_order_stats';

-		$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
+		// Filter the reporting period against the configured date type on wc_order_stats
+		// (date_paid by default) rather than the lookup's date_created, so the Taxes report
+		// reconciles with the Orders and Revenue reports.
+		$this->add_time_period_sql_params( $query_args, $order_stats_table );
 		$this->get_limit_sql_params( $query_args );
 		$this->add_order_by_sql_params( $query_args );
 		$order_status_filter = $this->get_status_subquery( $query_args );
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 a187b8a2718..ca82185bd1f 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/DataStore.php
@@ -60,19 +60,37 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	 */
 	protected $context = 'taxes_stats';

+	/**
+	 * Constructor.
+	 *
+	 * Report on the date type configured in Analytics settings (default `date_paid`),
+	 * matching the Orders and Revenue reports. The reporting period and intervals are
+	 * filtered against the chosen column on `wc_order_stats` so the Taxes report
+	 * reconciles with them.
+	 *
+	 * @override ReportsDataStore::__construct()
+	 */
+	public function __construct() {
+		$this->date_column_name = $this->sanitize_date_column_name( get_option( 'woocommerce_date_type' ), 'date_paid' );
+		parent::__construct();
+	}
+
 	/**
 	 * Assign report columns once full table name has been assigned.
 	 *
 	 * @override ReportsDataStore::assign_report_columns()
 	 */
 	protected function assign_report_columns() {
+		global $wpdb;
 		$table_name           = self::get_db_table_name();
+		$order_stats_table    = $wpdb->prefix . 'wc_order_stats';
 		$this->report_columns = array(
 			'tax_codes'    => 'COUNT(DISTINCT tax_rate_id) as tax_codes',
 			'total_tax'    => 'SUM(total_tax) AS total_tax',
 			'order_tax'    => 'SUM(order_tax) as order_tax',
 			'shipping_tax' => 'SUM(shipping_tax) as shipping_tax',
-			'orders_count' => "COUNT( DISTINCT ( CASE WHEN parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
+			// parent_id is qualified to wc_order_stats to stay unambiguous now that the join is always present.
+			'orders_count' => "COUNT( DISTINCT ( CASE WHEN {$order_stats_table}.parent_id = 0 THEN {$table_name}.order_id END ) ) as orders_count",
 		);
 	}

@@ -86,8 +104,12 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		global $wpdb;

 		$order_tax_lookup_table = self::get_db_table_name();
+		$order_stats_table      = $wpdb->prefix . 'wc_order_stats';

-		$this->add_time_period_sql_params( $query_args, $order_tax_lookup_table );
+		// Filter the reporting period against the configured date type on wc_order_stats
+		// (date_paid by default) rather than the lookup's date_created, so the Taxes report
+		// reconciles with the Orders and Revenue reports.
+		$this->add_time_period_sql_params( $query_args, $order_stats_table );
 		$taxes_where_clause  = '';
 		$order_status_filter = $this->get_status_subquery( $query_args );

@@ -104,7 +126,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {

 		$this->total_query->add_sql_clause( 'where', $taxes_where_clause );

-		$this->add_intervals_sql_params( $query_args, $order_tax_lookup_table );
+		$this->add_intervals_sql_params( $query_args, $order_stats_table );
 		$this->interval_query->add_sql_clause( 'where', $taxes_where_clause );
 		$this->interval_query->add_sql_clause( 'select', $this->get_sql_clause( 'select' ) . ' AS time_interval' );
 		$this->interval_query->add_sql_clause( 'where_time', $this->get_sql_clause( 'where_time' ) );
@@ -170,7 +192,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 	public function get_noncached_stats_data( $query_args, $params, &$data, $expected_interval_count ) {
 		global $wpdb;

-		$table_name = self::get_db_table_name();
+		$table_name        = self::get_db_table_name();
+		$order_stats_table = $wpdb->prefix . 'wc_order_stats';

 		$this->initialize_queries();

@@ -215,13 +238,13 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		$segmenter             = new Segmenter( $query_args, $this->report_columns );
 		$totals[0]['segments'] = $segmenter->get_totals_segments( $totals_query, $table_name );

-		$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $table_name );
+		$this->update_intervals_sql_params( $query_args, $db_interval_count, $expected_interval_count, $order_stats_table );

 		if ( '' !== $selections ) {
 			$this->interval_query->add_sql_clause( 'select', ', ' . $selections );
 		}

-		$this->interval_query->add_sql_clause( 'select', ", MAX({$table_name}.date_created) AS datetime_anchor" );
+		$this->interval_query->add_sql_clause( 'select', ", MAX({$order_stats_table}.{$this->date_column_name}) AS datetime_anchor" );
 		$this->interval_query->add_sql_clause( 'order_by', $this->get_sql_clause( 'order_by' ) );
 		$this->interval_query->add_sql_clause( 'limit', $this->get_sql_clause( 'limit' ) );

diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Segmenter.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Segmenter.php
index 7dfb799b771..944dca14037 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Segmenter.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Stats/Segmenter.php
@@ -92,6 +92,10 @@ class Segmenter extends ReportsSegmenter {
 			$segmenting_limit = $limit_parts[0] . ',' . $orig_rowcount * count( $this->get_all_segments() );
 		}

+		// Note: datetime_anchor is discarded before segments reach the response (see
+		// ReportsSegmenter::reformat_intervals_segments), so it still anchors on
+		// wc_order_tax_lookup.date_created while the rest of the report buckets by the
+		// configured wc_order_stats date column. Harmless today; revisit if it is ever surfaced.
 		$intervals_segments = $wpdb->get_results(
 			"SELECT
 						MAX($table_name.date_created) AS datetime_anchor,
diff --git a/plugins/woocommerce/src/Admin/ReportsSync.php b/plugins/woocommerce/src/Admin/ReportsSync.php
index c70b227b0c6..7ef88d05b1a 100644
--- a/plugins/woocommerce/src/Admin/ReportsSync.php
+++ b/plugins/woocommerce/src/Admin/ReportsSync.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Admin;

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Internal\Admin\Schedulers\CustomersScheduler;
 use Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler;
 use Automattic\WooCommerce\Internal\Admin\Schedulers\ImportScheduler;
@@ -27,6 +28,9 @@ class ReportsSync {
 		add_action( 'woocommerce_new_product', array( __CLASS__, 'clear_stock_count_cache' ) );
 		add_action( 'update_option_woocommerce_notify_low_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) );
 		add_action( 'update_option_woocommerce_notify_no_stock_amount', array( __CLASS__, 'clear_stock_count_cache' ) );
+		// Invalidate report caches when the analytics date type changes, so all report families
+		// (Orders, Revenue, Taxes) reflect the new date basis immediately instead of after the cache TTL.
+		add_action( 'update_option_woocommerce_date_type', array( ReportsCache::class, 'invalidate' ) );
 		add_action( 'trashed_post', array( __CLASS__, 'maybe_clear_stock_count_cache_for_post' ) );
 		add_action( 'untrashed_post', array( __CLASS__, 'maybe_clear_stock_count_cache_for_post' ) );
 		add_action( 'delete_post', array( __CLASS__, 'maybe_clear_stock_count_cache_for_post' ) );
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
new file mode 100644
index 00000000000..afb9291b37d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/API/Reports/Taxes/DataStoreTest.php
@@ -0,0 +1,327 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\API\Reports\Taxes;
+
+use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
+use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
+use Automattic\WooCommerce\Admin\ReportsSync;
+use Automattic\WooCommerce\Admin\API\Reports\Taxes\DataStore;
+use Automattic\WooCommerce\Admin\API\Reports\Taxes\Stats\DataStore as StatsDataStore;
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Helper_Order;
+use WC_Helper_Queue;
+use WC_Helper_Reports;
+use WC_Order_Item_Tax;
+use WC_Product_Simple;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that the Taxes reports honour the configured woocommerce_date_type,
+ * reconciling with the Orders and Revenue reports.
+ *
+ * @see https://github.com/woocommerce/woocommerce/issues/63699
+ */
+class DataStoreTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Original woocommerce_calc_taxes option value.
+	 *
+	 * @var string|false
+	 */
+	private $original_calc_taxes;
+
+	/**
+	 * Original woocommerce_date_type option value.
+	 *
+	 * @var string|false
+	 */
+	private $original_date_type;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_calc_taxes = get_option( 'woocommerce_calc_taxes' );
+		$this->original_date_type  = get_option( 'woocommerce_date_type' );
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		update_option( 'woocommerce_calc_taxes', $this->original_calc_taxes );
+		if ( false === $this->original_date_type ) {
+			delete_option( 'woocommerce_date_type' );
+		} else {
+			update_option( 'woocommerce_date_type', $this->original_date_type );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * Insert a DE VAT tax rate and return its id.
+	 *
+	 * @return int
+	 */
+	private function insert_tax_rate(): int {
+		global $wpdb;
+		$wpdb->insert(
+			$wpdb->prefix . 'woocommerce_tax_rates',
+			array(
+				'tax_rate_id'       => 1,
+				'tax_rate'          => '19',
+				'tax_rate_country'  => 'DE',
+				'tax_rate_state'    => '',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_order'    => 1,
+			)
+		);
+		return 1;
+	}
+
+	/**
+	 * Create a taxed, completed order and force its stats/lookup dates so that the
+	 * order's created, paid and completed dates can each land in a chosen month.
+	 *
+	 * @param int         $rate_id       Tax rate id to attach.
+	 * @param string      $created_gmt   Order creation datetime (GMT).
+	 * @param string|null $paid_gmt      Order payment datetime (GMT), or null for an unpaid (e.g. manual) order.
+	 * @param string|null $completed_gmt Order completion datetime (GMT). Defaults to $paid_gmt.
+	 * @return int Order id.
+	 */
+	private function seed_order( int $rate_id, string $created_gmt, ?string $paid_gmt, ?string $completed_gmt = null ): int {
+		global $wpdb;
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Repro Product' );
+		$product->set_regular_price( '100' );
+		$product->save();
+
+		$tax_item = new WC_Order_Item_Tax();
+		$tax_item->set_rate( $rate_id );
+		$tax_item->set_tax_total( 19 );
+		$tax_item->set_shipping_tax_total( 0 );
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->add_item( $tax_item );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->set_total( 119 );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$order_id = $order->get_id();
+
+		// Ensure a tax lookup row exists for the rate, dated by order creation.
+		$wpdb->replace(
+			$wpdb->prefix . 'wc_order_tax_lookup',
+			array(
+				'order_id'     => $order_id,
+				'tax_rate_id'  => $rate_id,
+				'date_created' => $created_gmt,
+				'shipping_tax' => 0,
+				'order_tax'    => 19,
+				'total_tax'    => 19,
+			)
+		);
+
+		// Force the created/paid/completed dates on the stats row.
+		$wpdb->update(
+			$wpdb->prefix . 'wc_order_stats',
+			array(
+				'date_created'     => $created_gmt,
+				'date_created_gmt' => $created_gmt,
+				'date_paid'        => $paid_gmt,
+				'date_completed'   => null === $completed_gmt ? $paid_gmt : $completed_gmt,
+			),
+			array( 'order_id' => $order_id )
+		);
+
+		ReportsCache::invalidate();
+
+		return $order_id;
+	}
+
+	/**
+	 * Build query args for a whole-month Taxes report request.
+	 *
+	 * @param string $after   Period start (GMT).
+	 * @param string $before  Period end (GMT).
+	 * @param int    $rate_id Tax rate id to scope to.
+	 * @return array
+	 */
+	private function taxes_query( string $after, string $before, int $rate_id ): array {
+		return array(
+			'after'    => $after,
+			'before'   => $before,
+			'taxes'    => array( $rate_id ),
+			'per_page' => 100,
+			'page'     => 1,
+		);
+	}
+
+	/**
+	 * @testdox Taxes table report counts an order in its paid month, not its created month, when reporting by date_paid.
+	 */
+	public function test_taxes_report_buckets_by_date_paid_when_configured(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$this->seed_order( $rate_id, '2023-01-15 10:00:00', '2023-02-15 10:00:00' );
+
+		$sut = new DataStore();
+
+		$february = $sut->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) );
+		$january  = $sut->get_data( $this->taxes_query( '2023-01-01 00:00:00', '2023-01-31 23:59:59', $rate_id ) );
+
+		$february_count = isset( $february->data[0]['orders_count'] ) ? $february->data[0]['orders_count'] : 0;
+
+		$this->assertSame( 1, $february_count, 'Order paid in February should be counted in the February Taxes report.' );
+		$this->assertCount( 0, $january->data, 'Order paid in February should not appear in the January Taxes report.' );
+	}
+
+	/**
+	 * @testdox Taxes stats report counts an order in its paid month, not its created month, when reporting by date_paid.
+	 */
+	public function test_taxes_stats_report_buckets_by_date_paid_when_configured(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		$this->seed_order( $rate_id, '2023-01-15 10:00:00', '2023-02-15 10:00:00' );
+
+		$sut = new StatsDataStore();
+
+		$february = $sut->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) + array( 'interval' => 'day' ) );
+		$january  = $sut->get_data( $this->taxes_query( '2023-01-01 00:00:00', '2023-01-31 23:59:59', $rate_id ) + array( 'interval' => 'day' ) );
+
+		$this->assertSame( 1, $february->totals->orders_count, 'Order paid in February should be counted in the February Taxes stats totals.' );
+		$this->assertSame( 0, $january->totals->orders_count, 'Order paid in February should not appear in the January Taxes stats totals.' );
+
+		// The order must land in its paid-date interval (Feb 15), not its created-date interval.
+		$february_intervals_with_order = array_values(
+			array_filter(
+				$february->intervals,
+				function ( $interval ) {
+					return $interval['subtotals']->orders_count > 0;
+				}
+			)
+		);
+		$this->assertCount( 1, $february_intervals_with_order, 'Exactly one February interval should contain the order.' );
+		$this->assertSame( '2023-02-15', $february_intervals_with_order[0]['interval'], 'The order should be bucketed into its paid date (Feb 15), not its created date.' );
+		$this->assertSame( 1, $february_intervals_with_order[0]['subtotals']->orders_count );
+
+		// No January interval should contain the order, since it is paid in February.
+		foreach ( $january->intervals as $interval ) {
+			$this->assertSame( 0, $interval['subtotals']->orders_count, 'No January interval should contain an order paid in February.' );
+		}
+	}
+
+	/**
+	 * @testdox Taxes table report counts an order in its completed month when reporting by date_completed.
+	 */
+	public function test_taxes_report_buckets_by_date_completed_when_configured(): void {
+		update_option( 'woocommerce_date_type', 'date_completed' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		// Created and paid in January, but completed in February.
+		$this->seed_order( $rate_id, '2023-01-15 10:00:00', '2023-01-15 10:00:00', '2023-02-15 10:00:00' );
+
+		$sut = new DataStore();
+
+		$february = $sut->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) );
+		$january  = $sut->get_data( $this->taxes_query( '2023-01-01 00:00:00', '2023-01-31 23:59:59', $rate_id ) );
+
+		$february_count = isset( $february->data[0]['orders_count'] ) ? $february->data[0]['orders_count'] : 0;
+
+		$this->assertSame( 1, $february_count, 'Order completed in February should be counted in the February Taxes report.' );
+		$this->assertCount( 0, $january->data, 'Order completed in February should not appear in the January Taxes report when reporting by date_completed.' );
+	}
+
+	/**
+	 * @testdox Taxes table report excludes an order with no paid date when reporting by date_paid.
+	 */
+	public function test_taxes_report_excludes_orders_with_no_paid_date(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		// Manually-created order: created in February but never paid (date_paid NULL).
+		$this->seed_order( $rate_id, '2023-02-15 10:00:00', null );
+
+		$sut = new DataStore();
+
+		$february = $sut->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) );
+
+		$this->assertCount( 0, $february->data, 'An order with no paid date should be excluded from the date_paid based Taxes report, matching the Orders report.' );
+	}
+
+	/**
+	 * @testdox Taxes stats report excludes an order with no paid date when reporting by date_paid.
+	 */
+	public function test_taxes_stats_report_excludes_orders_with_no_paid_date(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		// Manually-created order: created in February but never paid (date_paid NULL).
+		$this->seed_order( $rate_id, '2023-02-15 10:00:00', null );
+
+		$sut = new StatsDataStore();
+
+		$february = $sut->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) + array( 'interval' => 'day' ) );
+
+		$this->assertSame( 0, $february->totals->orders_count, 'An order with no paid date should be excluded from the date_paid based Taxes stats totals.' );
+	}
+
+	/**
+	 * @testdox Changing the analytics date type is wired to invalidate the report cache so reports do not serve stale numbers.
+	 */
+	public function test_changing_date_type_invalidates_report_cache(): void {
+		ReportsSync::init();
+
+		$this->assertNotFalse(
+			has_action( 'update_option_woocommerce_date_type', array( ReportsCache::class, 'invalidate' ) ),
+			'Changing the analytics date type should invalidate the report cache so all report families reflect the new basis immediately.'
+		);
+	}
+
+	/**
+	 * @testdox Taxes report and Orders report agree on the order count for the same tax rate and period.
+	 */
+	public function test_taxes_report_reconciles_with_orders_report(): void {
+		update_option( 'woocommerce_date_type', 'date_paid' );
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$rate_id = $this->insert_tax_rate();
+		// Two orders paid in February, plus one manual order created in February with no paid date.
+		$this->seed_order( $rate_id, '2023-02-10 10:00:00', '2023-02-12 10:00:00' );
+		$this->seed_order( $rate_id, '2023-02-14 10:00:00', '2023-02-16 10:00:00' );
+		$this->seed_order( $rate_id, '2023-02-18 10:00:00', null );
+
+		$taxes  = new DataStore();
+		$orders = new OrdersDataStore();
+
+		$taxes_data  = $taxes->get_data( $this->taxes_query( '2023-02-01 00:00:00', '2023-02-28 23:59:59', $rate_id ) );
+		$orders_data = $orders->get_data(
+			array(
+				'after'             => '2023-02-01 00:00:00',
+				'before'            => '2023-02-28 23:59:59',
+				'tax_rate_includes' => array( $rate_id ),
+				'per_page'          => 100,
+				'page'              => 1,
+			)
+		);
+
+		$taxes_count = isset( $taxes_data->data[0]['orders_count'] ) ? $taxes_data->data[0]['orders_count'] : 0;
+
+		$this->assertSame( 2, $taxes_count, 'Only the two paid orders should be counted in the Taxes report.' );
+		$this->assertSame( $taxes_count, (int) $orders_data->total, 'Taxes and Orders reports should agree on the order count for the same tax rate and period.' );
+	}
+}