Commit 6a621e35c0a for woocommerce

commit 6a621e35c0ae9b1c6ff63abeaa17184991178450
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Thu Aug 20 18:12:08 2026 +0300

    Fix date_paid order filter ignoring the site timezone (#67134)

    * fix(orders): resolve the date filter day in the site timezone

    The Analytics > Revenue orders link filters the legacy orders list with
    order_date_type=date_paid&m=YYYYMMDD, where the day is the merchant's
    local day. The list table built both bounds with
    DateTime::createFromFormat() and no timezone, which resolves to UTC
    because WordPress forces the default timezone there. For any non-UTC
    store the window was shifted by the store's offset: on a UTC-4 store,
    asking for July 25 returned orders paid from 8pm on July 24.

    Build the start with wp_timezone(), which also covers stores configured
    with a manual UTC offset rather than a named zone, and derive the end
    from the next local midnight instead of parsing a literal 23:59:59.
    Parsing 23:59:59 picks the first occurrence of that time, so on a day
    where DST ends at midnight the local day lasts 25 hours and the range
    stopped an hour early. modify( 'tomorrow' ) lands on midnight regardless
    of the start time, where '+1 day' would carry a normalised 01:00 start
    into the following day. The remaining limitation is stated in the code:
    where a zone moves its clocks back onto midnight, 00:00 repeats and an
    hour falls between two day filters. Every such transition in the current
    tz database is from 2021 or earlier.

    Guard the branch with is_string(). wc_clean( wp_unslash( $_GET['m'] ) )
    returns an array for ?m[]=x, and interpolating it raised an "Array to
    string conversion" warning before createFromFormat() returned false.
    Non-string values now fall through to WordPress, which normalises a
    non-scalar m to an empty string. That removes the last unnarrowed
    interpolation of $date_query, so the baselined
    encapsedStringPart.nonString entry drops to zero and is deleted rather
    than decremented.

    The parse_query query vars change with this: the meta_value bounds shift
    by the store's UTC offset, so anything reading the orders list query on
    parse_query, pre_get_posts or posts_clauses sees different values for
    the same URL. The key, the array shape and the BETWEEN predicate are
    unchanged, and on UTC stores the bounds are byte-identical.

    Refs #39462

    * test(orders): cover timezone, DST and boundary cases for the date filter

    The date_paid and date_completed day filters had no coverage, so every
    element of the fix could be reverted with the suite still green.

    Each test pins one production decision and fails when that decision is
    reverted: the store timezone for named zones and for a manual UTC
    offset, the same for date_completed, the 25-hour DST day that a literal
    23:59:59 end cuts short, the 23-hour day where '+1 day' would spill into
    the next, the subtraction that makes the next local midnight exclusive,
    and the is_string() guard.

    Every assertion also carries its exclusion side. The production code
    unsets the native m query var before the meta branch runs, so deleting
    that branch leaves the query with no date restriction at all and an
    inclusion-only assertion still passes against a completely unfiltered
    list.

    Refs #39462

    * test(orders): restore the temporary error handler in a finally block

    Both no-warning tests install an error handler that records every
    diagnostic and returns true, suppressing PHP's normal handling. Each
    called restore_error_handler() on the straight-line path after the
    query, so a throw from WP_Query or get_posts() would skip it and leave
    the suppressing handler installed for the rest of the PHPUnit process,
    silently swallowing warnings in every later test.

    Unlike the option fixtures in this file, an error handler is PHP process
    state, so the per-test transaction rollback does not undo it.

    Wrap both queries in try/finally. The pre-existing search test from
    #55353 carries the same pattern, so fixing only the new one would leave
    the file inconsistent.

    Refs #39462

diff --git a/plugins/woocommerce/changelog/fix-39462-date-paid-filter-timezone b/plugins/woocommerce/changelog/fix-39462-date-paid-filter-timezone
new file mode 100644
index 00000000000..70a23459767
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39462-date-paid-filter-timezone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make the date_paid/date_completed day filter on the legacy orders list table respect the site timezone, so the listed orders match the requested local day.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
index 20c24b39ccd..b39b130e5fb 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
@@ -714,15 +714,20 @@ class WC_Admin_List_Table_Orders extends WC_Admin_List_Table {
 			$date_type  = wc_clean( wp_unslash( $_GET['order_date_type'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 			$date_query = wc_clean( wp_unslash( $_GET['m'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
 			// date_paid and date_completed are stored in postmeta, so we need to do a meta query.
-			if ( 'date_paid' === $date_type || 'date_completed' === $date_type ) {
-				$date_start = \DateTime::createFromFormat( 'Ymd H:i:s', "$date_query 00:00:00" );
-				$date_end   = \DateTime::createFromFormat( 'Ymd H:i:s', "$date_query 23:59:59" );
+			if ( is_string( $date_query ) && ( 'date_paid' === $date_type || 'date_completed' === $date_type ) ) {
+				// The postmeta values are UTC timestamps, while the requested day is in the site's timezone.
+				$date_start = \DateTime::createFromFormat( 'Ymd H:i:s', "$date_query 00:00:00", wp_timezone() );

 				unset( $wp->query_vars['m'] );

-				if ( $date_start && $date_end ) {
+				if ( $date_start ) {
+					// Use the next local midnight so the range follows DST-shortened or extended days.
+					// 'tomorrow' resets to midnight; '+1 day' can retain a normalized 01:00 start.
+					// Midnight rollbacks before 2022 may still leave the first repeated hour uncovered.
+					$date_end = ( clone $date_start )->modify( 'tomorrow' );
+
 					$wp->query_vars['meta_key']     = "_$date_type"; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
-					$wp->query_vars['meta_value']   = array( strval( $date_start->getTimestamp() ), strval( $date_end->getTimestamp() ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+					$wp->query_vars['meta_value']   = array( strval( $date_start->getTimestamp() ), strval( $date_end->getTimestamp() - 1 ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
 					$wp->query_vars['meta_compare'] = 'BETWEEN';
 				}
 			}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index ae53468e843..e42684ffa17 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -4710,12 +4710,6 @@ parameters:
 			count: 1
 			path: includes/admin/list-tables/class-wc-admin-list-table-orders.php

-		-
-			message: '#^Part \$date_query \(array\|string\) of encapsed string cannot be cast to string\.$#'
-			identifier: encapsedStringPart.nonString
-			count: 2
-			path: includes/admin/list-tables/class-wc-admin-list-table-orders.php
-
 		-
 			message: '#^Property WC_Admin_List_Table\:\:\$object \(object\|null\) does not accept WC_Order\|WC_Order_Refund\|false\.$#'
 			identifier: assign.propertyType
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
index c32fa74567a..0df237acba4 100644
--- a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
@@ -272,6 +272,185 @@ class WC_Admin_List_Table_Orders_Test extends WC_Unit_Test_Case {
 		wp_delete_post( $dummy_order->get_id(), true );
 	}

+	/**
+	 * Creates a paid order with date_paid set from a local-time string.
+	 *
+	 * @param string $local_datetime Local date/time string, e.g. '2023-07-20 21:00:00'.
+	 * @return WC_Order
+	 */
+	private function create_order_paid_at( string $local_datetime ): WC_Order {
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_date_paid( ( new DateTime( $local_datetime, wp_timezone() ) )->getTimestamp() );
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Runs an order list table date query.
+	 *
+	 * @param string $date_type Order date field to filter.
+	 * @param string $date      Date in Ymd format.
+	 * @return int[] Matching order IDs.
+	 */
+	private function query_order_ids_with_date_filter( string $date_type, string $date ): array {
+		$_GET['order_date_type'] = $date_type;
+		$_GET['m']               = $date;
+		$GLOBALS['pagenow']      = 'edit.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		new WC_Admin_List_Table_Orders();
+		$query = new WP_Query(
+			array(
+				'post_type'   => 'shop_order',
+				'post_status' => 'all',
+				'fields'      => 'ids',
+				'm'           => $date,
+			)
+		);
+
+		$results = $query->get_posts();
+
+		unset( $_GET['order_date_type'], $_GET['m'], $GLOBALS['pagenow'] );
+
+		return $results;
+	}
+
+	/**
+	 * @testdox Should interpret the date_paid day filter in the store timezone, not UTC.
+	 */
+	public function test_date_paid_filter_uses_store_timezone(): void {
+		update_option( 'timezone_string', 'America/New_York' );
+
+		$morning_order      = $this->create_order_paid_at( '2023-07-20 09:00:00' );
+		$evening_order      = $this->create_order_paid_at( '2023-07-20 21:00:00' );
+		$previous_day_order = $this->create_order_paid_at( '2023-07-19 21:00:00' );
+
+		$results = $this->query_order_ids_with_date_filter( 'date_paid', '20230720' );
+
+		$this->assertContains( $morning_order->get_id(), $results, 'Order paid in the morning (store time) should be listed for its local day.' );
+		$this->assertContains( $evening_order->get_id(), $results, 'Order paid late evening (store time) should be listed for its local day even though it falls on the next day in UTC.' );
+		$this->assertNotContains( $previous_day_order->get_id(), $results, 'Order paid the previous local day should not be listed even though it falls on the filtered day in UTC.' );
+
+		update_option( 'timezone_string', '' );
+		foreach ( array( $morning_order, $evening_order, $previous_day_order ) as $order ) {
+			wp_delete_post( $order->get_id(), true );
+		}
+	}
+
+	/**
+	 * @testdox Should respect a manual UTC offset (empty timezone_string) when filtering by date_paid.
+	 */
+	public function test_date_paid_filter_uses_store_timezone_with_manual_utc_offset(): void {
+		update_option( 'timezone_string', '' );
+		update_option( 'gmt_offset', -4 );
+
+		$evening_order      = $this->create_order_paid_at( '2023-07-20 21:00:00' );
+		$previous_day_order = $this->create_order_paid_at( '2023-07-19 21:00:00' );
+
+		$results = $this->query_order_ids_with_date_filter( 'date_paid', '20230720' );
+
+		$this->assertContains( $evening_order->get_id(), $results, 'Order paid late evening (offset local time) should be listed for its local day.' );
+		$this->assertNotContains( $previous_day_order->get_id(), $results, 'Order paid the previous local day should not be listed.' );
+
+		update_option( 'gmt_offset', 0 );
+		foreach ( array( $evening_order, $previous_day_order ) as $order ) {
+			wp_delete_post( $order->get_id(), true );
+		}
+	}
+
+	/**
+	 * @testdox Should filter date_completed day ranges in the store timezone as well.
+	 */
+	public function test_date_completed_filter_uses_store_timezone(): void {
+		update_option( 'timezone_string', 'America/New_York' );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_date_completed( ( new DateTime( '2023-07-20 21:00:00', wp_timezone() ) )->getTimestamp() );
+		$order->save();
+
+		$previous_day_order = WC_Helper_Order::create_order();
+		$previous_day_order->set_status( 'completed' );
+		$previous_day_order->set_date_completed( ( new DateTime( '2023-07-19 21:00:00', wp_timezone() ) )->getTimestamp() );
+		$previous_day_order->save();
+
+		$results = $this->query_order_ids_with_date_filter( 'date_completed', '20230720' );
+
+		$this->assertContains( $order->get_id(), $results, 'Order completed late evening (store time) should be listed for its local day.' );
+		$this->assertNotContains( $previous_day_order->get_id(), $results, 'Order completed the previous local day should not be listed even though it falls on the filtered day in UTC.' );
+
+		update_option( 'timezone_string', '' );
+		foreach ( array( $order, $previous_day_order ) as $created_order ) {
+			wp_delete_post( $created_order->get_id(), true );
+		}
+	}
+
+	/**
+	 * @testdox Should cover the whole local day when DST ends at midnight and the day lasts 25 hours.
+	 */
+	public function test_date_paid_filter_covers_dst_extended_day(): void {
+		// Chile ends DST at midnight, so 2022-04-02 lasts 25 hours and repeats its 23:00 hour.
+		update_option( 'timezone_string', 'America/Santiago' );
+
+		$next_midnight = ( new DateTime( '2022-04-02 00:00:00', wp_timezone() ) )->modify( '+1 day' );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_date_paid( $next_midnight->getTimestamp() - 1800 );
+		$order->save();
+
+		$results = $this->query_order_ids_with_date_filter( 'date_paid', '20220402' );
+
+		$this->assertContains( $order->get_id(), $results, 'An order paid during the repeated final hour of a 25-hour local day should still be listed for that day.' );
+
+		update_option( 'timezone_string', '' );
+		wp_delete_post( $order->get_id(), true );
+	}
+
+	/**
+	 * @testdox Should not spill into the next day when DST starts at midnight and the day has no 00:00.
+	 */
+	public function test_date_paid_filter_does_not_overlap_dst_shortened_day(): void {
+		// Chile starts DST at midnight, so 2022-09-11 has no 00:00 hour and begins at 01:00.
+		update_option( 'timezone_string', 'America/Santiago' );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_date_paid( ( new DateTime( '2022-09-12 00:30:00', wp_timezone() ) )->getTimestamp() );
+		$order->save();
+
+		$previous_day_results = $this->query_order_ids_with_date_filter( 'date_paid', '20220911' );
+		$own_day_results      = $this->query_order_ids_with_date_filter( 'date_paid', '20220912' );
+
+		$this->assertNotContains( $order->get_id(), $previous_day_results, 'An order paid after midnight the following day should not also be listed under the previous day.' );
+		$this->assertContains( $order->get_id(), $own_day_results, 'The order should be listed under the day it was actually paid.' );
+
+		update_option( 'timezone_string', '' );
+		wp_delete_post( $order->get_id(), true );
+	}
+
+	/**
+	 * @testdox Should treat the next local midnight as the exclusive end of the day.
+	 */
+	public function test_date_paid_filter_excludes_the_next_midnight_instant(): void {
+		update_option( 'timezone_string', 'America/New_York' );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( 'completed' );
+		$order->set_date_paid( ( new DateTime( '2026-07-21 00:00:00', wp_timezone() ) )->getTimestamp() );
+		$order->save();
+
+		$filtered_day_results = $this->query_order_ids_with_date_filter( 'date_paid', '20260720' );
+		$own_day_results      = $this->query_order_ids_with_date_filter( 'date_paid', '20260721' );
+
+		$this->assertNotContains( $order->get_id(), $filtered_day_results, 'An order paid exactly at midnight belongs to the day starting then, not the one ending then.' );
+		$this->assertContains( $order->get_id(), $own_day_results, 'An order paid exactly at midnight should be listed under the day that starts at that instant.' );
+
+		update_option( 'timezone_string', '' );
+		wp_delete_post( $order->get_id(), true );
+	}
+
 	/**
 	 * Test that the search without post_type in query does not trigger warnings.
 	 * This is a regression test for https://github.com/woocommerce/woocommerce/pull/55353.
@@ -288,15 +467,17 @@ class WC_Admin_List_Table_Orders_Test extends WC_Unit_Test_Case {
 			}
 		);

-		// Do not set post_type in the query.
-		new WP_Query(
-			array(
-				'post_status' => 'all',
-				'fields'      => 'ids',
-			)
-		);
-
-		restore_error_handler();
+		try {
+			// Do not set post_type in the query.
+			new WP_Query(
+				array(
+					'post_status' => 'all',
+					'fields'      => 'ids',
+				)
+			);
+		} finally {
+			restore_error_handler();
+		}

 		// Check no warnings were triggered.
 		$this->assertEmpty(
@@ -307,4 +488,49 @@ class WC_Admin_List_Table_Orders_Test extends WC_Unit_Test_Case {
 		// Cleanup.
 		unset( $GLOBALS['pagenow'] );
 	}
+
+	/**
+	 * @testdox Should not warn or drop orders when the m parameter is not a string.
+	 */
+	public function test_date_filter_with_non_string_m_does_not_trigger_warning(): void {
+		$order = $this->create_order_paid_at( '2023-07-20 21:00:00' );
+
+		$_GET['order_date_type'] = 'date_paid';
+		$_GET['m']               = array( '20230720' );
+		$GLOBALS['pagenow']      = 'edit.php'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		new WC_Admin_List_Table_Orders();
+
+		$errors = array();
+		set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
+			function ( $errno, $errstr ) use ( &$errors ) {
+				$errors[] = "[$errno] $errstr";
+
+				return true;
+			}
+		);
+
+		try {
+			$query = new WP_Query(
+				array(
+					'post_type'      => 'shop_order',
+					'post_status'    => 'all',
+					'fields'         => 'ids',
+					'posts_per_page' => -1,
+					'm'              => array( '20230720' ),
+				)
+			);
+
+			$results = $query->get_posts();
+		} finally {
+			restore_error_handler();
+		}
+
+		$this->assertSame( array(), $errors, 'An array "m" parameter should not raise an "Array to string conversion" warning.' );
+		$this->assertEmpty( $query->query_vars['meta_key'], 'An unusable "m" parameter should not build a date meta query.' );
+		$this->assertContains( $order->get_id(), $results, 'An unusable "m" parameter should leave the list unfiltered rather than silently dropping orders.' );
+
+		unset( $_GET['order_date_type'], $_GET['m'], $GLOBALS['pagenow'] );
+		wp_delete_post( $order->get_id(), true );
+	}
 }