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.
*/