Commit e7a85666374 for woocommerce

commit e7a85666374f0042cbed6bf80424cee3ae12907f
Author: Akanshu Singh <61490175+singhakanshu00@users.noreply.github.com>
Date:   Fri Oct 16 21:25:11 2026 +0530

    fix: Correct timezone conversion in HPOS date filters (#69752)

    * fix: correct timezone conversion in HPOS date filters

    * feat: Adds test cases

diff --git a/plugins/woocommerce/changelog/fix-hpos-rest-api-date-filter-timezone b/plugins/woocommerce/changelog/fix-hpos-rest-api-date-filter-timezone
new file mode 100644
index 00000000000..61a5dbf392d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-rest-api-date-filter-timezone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix order date filters on non-UTC stores for dates with fractional seconds or hour-only time zone offsets.
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
index d702cb38b5d..8ce3a1b86fb 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableQuery.php
@@ -614,8 +614,10 @@ class OrdersTableQuery {
 			$query['column']  = $local_to_gmt_date_keys[ $query['column'] ];
 			$op               = isset( $query['after'] ) ? 'after' : 'before';
 			$date_value_local = $query[ $op ];
-			$date_value_gmt   = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
-			$query[ $op ]     = $this->date_to_date_query_arg( $date_value_gmt );
+			// date_create() honors an offset in the string; wc_string_to_datetime() re-applies the site offset to formats like "10:00:00.500Z" or "+02".
+			$parsed_date    = is_string( $date_value_local ) && ! is_numeric( $date_value_local ) ? date_create( $date_value_local, wp_timezone() ) : false;
+			$date_value_gmt = $parsed_date ? $parsed_date->getTimestamp() : wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
+			$query[ $op ]   = $this->date_to_date_query_arg( $date_value_gmt );
 		}

 		return $query;
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index d35b08e84f4..ef89a85c85d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -3744,6 +3744,131 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		$this->assertEquals( 0, count( $query->orders ) );
 	}

+	/**
+	 * @testdox A 'date_query' value on a local date column is converted to UTC once, whatever its format.
+	 * @testWith ["after", "2024-05-01T10:00:00.500Z", ["late"]]
+	 *           ["before", "2024-05-01T10:00:00.500Z", ["early", "middle"]]
+	 *           ["after", "2024-05-01T12:00:00+02", ["late"]]
+	 *           ["before", "2024-05-01T12:00:00+02", ["early", "middle"]]
+	 *           ["after", "2024-05-01T10:00:00Z", ["late"]]
+	 *           ["after", "2024-05-01 15:30:00", ["late"]]
+	 *           ["before", "2024-05-01 15:30:00", ["early", "middle"]]
+	 *
+	 * @param string   $operator        The date query operator, 'after' or 'before'.
+	 * @param string   $date_value      A date equal to 10:00 UTC, in the format a REST API client may send.
+	 * @param string[] $expected_orders Labels of the orders the query should return.
+	 */
+	public function test_local_date_query_converts_date_values_to_utc_once( string $operator, string $date_value, array $expected_orders ): void {
+		$order_ids = array(
+			'early'  => $this->create_order_created_at( '2024-05-01T04:00:00Z' ),
+			// Falls between the correct boundary (10:00 UTC) and 04:30 UTC, where applying the +05:30 site offset twice puts it.
+			'middle' => $this->create_order_created_at( '2024-05-01T07:00:00Z' ),
+			'late'   => $this->create_order_created_at( '2024-05-01T11:00:00Z' ),
+		);
+
+		$query = new OrdersTableQuery(
+			array(
+				'date_query' => array(
+					array(
+						'column'  => 'post_date',
+						$operator => $date_value,
+					),
+				),
+			)
+		);
+
+		foreach ( $order_ids as $label => $order_id ) {
+			$this->assertSame(
+				in_array( $label, $expected_orders, true ),
+				in_array( $order_id, $query->orders, true ),
+				"Unexpected result for the '{$label}' order with '{$operator}' => '{$date_value}'."
+			);
+		}
+	}
+
+	/**
+	 * @testdox A relative 'date_query' value on a local date column is resolved in the site timezone without an extra offset.
+	 */
+	public function test_local_date_query_resolves_relative_values_once(): void {
+		$now       = time();
+		$recent_id = $this->create_order_created_at( $now - HOUR_IN_SECONDS );
+		// Falls between the correct boundary (one day ago) and one moved back by the +05:30 site offset.
+		$boundary_id = $this->create_order_created_at( $now - DAY_IN_SECONDS - ( 3 * HOUR_IN_SECONDS ) );
+		$old_id      = $this->create_order_created_at( $now - ( 3 * DAY_IN_SECONDS ) );
+
+		$query = new OrdersTableQuery(
+			array(
+				'date_query' => array(
+					array(
+						'column' => 'post_date',
+						'after'  => '-1 day',
+					),
+				),
+			)
+		);
+
+		$this->assertContains( $recent_id, $query->orders, 'An order from an hour ago should match "after -1 day".' );
+		$this->assertNotContains( $boundary_id, $query->orders, 'An order from 27 hours ago should not match "after -1 day".' );
+		$this->assertNotContains( $old_id, $query->orders, 'An order from three days ago should not match "after -1 day".' );
+	}
+
+	/**
+	 * @testdox An unparseable 'date_query' value on a local date column keeps its existing behavior.
+	 */
+	public function test_local_date_query_keeps_existing_behavior_for_unparseable_values(): void {
+		$order_id = $this->create_order_created_at( '2024-05-01T11:00:00Z' );
+
+		$query = new OrdersTableQuery(
+			array(
+				'date_query' => array(
+					array(
+						'column' => 'post_date',
+						'after'  => 'not a date',
+					),
+				),
+			)
+		);
+
+		// wc_string_to_datetime() resolves an unparseable value to the Unix epoch, so every order matches.
+		$this->assertContains( $order_id, $query->orders, 'An unparseable "after" value should still resolve to the Unix epoch.' );
+	}
+
+	/**
+	 * @testdox A WC_DateTime 'date_query' value on a local date column keeps its existing behavior.
+	 */
+	public function test_local_date_query_keeps_existing_behavior_for_wc_datetime_values(): void {
+		$earlier_id = $this->create_order_created_at( '2024-05-01T07:00:00Z' );
+		$later_id   = $this->create_order_created_at( '2024-05-01T11:00:00Z' );
+
+		$query = new OrdersTableQuery(
+			array(
+				'date_query' => array(
+					array(
+						'column' => 'post_date',
+						'after'  => new \WC_DateTime( '2024-05-01 10:00:00', new DateTimeZone( 'UTC' ) ),
+					),
+				),
+			)
+		);
+
+		$this->assertNotContains( $earlier_id, $query->orders, 'An order created before 10:00 UTC should not match.' );
+		$this->assertContains( $later_id, $query->orders, 'An order created after 10:00 UTC should match.' );
+	}
+
+	/**
+	 * Create an order in the orders table with the given creation date.
+	 *
+	 * @param string|int $date_created A creation date accepted by WC_Order::set_date_created().
+	 * @return int The order ID.
+	 */
+	private function create_order_created_at( $date_created ): int {
+		$order = new WC_Order();
+		$order->set_date_created( $date_created );
+		$this->sut->create( $order );
+
+		return $order->get_id();
+	}
+
 	/**
 	 * @testDox Hooking into woocommerce_delete_shop_order_transients does not cause data loss.
 	 */