Commit d4d9ebc7d08 for woocommerce
commit d4d9ebc7d08aef0d1928a7d2cf4577153f85d81f
Author: Vincent Borgers <88669109+VincentBorgers@users.noreply.github.com>
Date: Mon Oct 26 13:19:27 2026 +0100
Apply the store timezone once in the orders query created date filter (#69723)
diff --git a/plugins/woocommerce/changelog/fix-orders-query-created-date-timezone b/plugins/woocommerce/changelog/fix-orders-query-created-date-timezone
new file mode 100644
index 00000000000..d8f391a909c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-orders-query-created-date-timezone
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return the right orders from the orders query ability when filtering by creation or modification date and time on stores outside UTC.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 6e2a60d372b..395d4dff2da 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -145,10 +145,9 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
$args['exclude'] = array_map( 'intval', $input['exclude'] );
}
- foreach ( array( 'date_after', 'date_before' ) as $field ) {
- if ( ! empty( $input[ $field ] ) ) {
- $args[ $field ] = wc_clean( $input[ $field ] );
- }
+ $created_date_arg = self::build_created_date_arg( $input );
+ if ( null !== $created_date_arg ) {
+ $args['date_created'] = $created_date_arg;
}
$modified_date_query = self::build_modified_date_query_arg( $input );
@@ -292,22 +291,22 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
),
'date_after' => array(
'type' => 'string',
- 'description' => __( 'Filter orders created after this date/time.', 'woocommerce' ),
+ 'description' => __( 'Filter orders created after this date and time, in ISO 8601 format. A value without a UTC offset, such as 2025-07-15T10:00:00, is in the store timezone. Add Z or an offset such as +02:00 to use another timezone.', 'woocommerce' ),
'format' => 'date-time',
),
'date_before' => array(
'type' => 'string',
- 'description' => __( 'Filter orders created before this date/time.', 'woocommerce' ),
+ 'description' => __( 'Filter orders created before this date and time, in ISO 8601 format. A value without a UTC offset, such as 2025-07-15T10:00:00, is in the store timezone. Add Z or an offset such as +02:00 to use another timezone.', 'woocommerce' ),
'format' => 'date-time',
),
'modified_after' => array(
'type' => 'string',
- 'description' => __( 'Filter orders modified after this date/time.', 'woocommerce' ),
+ 'description' => __( 'Filter orders modified after this date and time, in ISO 8601 format. A value without a UTC offset, such as 2025-07-15T10:00:00, is in the store timezone. Add Z or an offset such as +02:00 to use another timezone.', 'woocommerce' ),
'format' => 'date-time',
),
'modified_before' => array(
'type' => 'string',
- 'description' => __( 'Filter orders modified before this date/time.', 'woocommerce' ),
+ 'description' => __( 'Filter orders modified before this date and time, in ISO 8601 format. A value without a UTC offset, such as 2025-07-15T10:00:00, is in the store timezone. Add Z or an offset such as +02:00 to use another timezone.', 'woocommerce' ),
'format' => 'date-time',
),
'orderby' => array(
@@ -345,29 +344,45 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
}
/**
- * Build a modified-date query arg from modified_after/modified_before input.
+ * Build a date_created arg from date_after/date_before input.
+ *
+ * The timestamps are UTC, so wc_get_orders() compares them directly instead of
+ * applying the store offset to the value a second time.
*
* @param array $input Ability input.
- * @return array|null
+ * @return string|null Null when there is no valid created-date input.
*/
- private static function build_modified_date_query_arg( array $input ): ?array {
- $after = isset( $input['modified_after'] ) && is_string( $input['modified_after'] ) ? sanitize_text_field( $input['modified_after'] ) : '';
- $before = isset( $input['modified_before'] ) && is_string( $input['modified_before'] ) ? sanitize_text_field( $input['modified_before'] ) : '';
+ private static function build_created_date_arg( array $input ): ?string {
+ $timestamps = self::resolve_date_range_timestamps( $input, 'date_after', 'date_before' );
- if ( '' === $after && '' === $before ) {
+ if ( null === $timestamps ) {
return null;
}
- $after_timestamp = '' !== $after ? self::prepare_date_time_for_query( $after ) : null;
- $before_timestamp = '' !== $before ? self::prepare_date_time_for_query( $before ) : null;
+ list( $after_timestamp, $before_timestamp ) = $timestamps;
- if (
- ( '' !== $after && null === $after_timestamp )
- || ( '' !== $before && null === $before_timestamp )
- ) {
+ if ( null !== $after_timestamp && null !== $before_timestamp ) {
+ return $after_timestamp . '...' . $before_timestamp;
+ }
+
+ return null !== $after_timestamp ? '>' . $after_timestamp : '<' . $before_timestamp;
+ }
+
+ /**
+ * Build a modified-date query arg from modified_after/modified_before input.
+ *
+ * @param array $input Ability input.
+ * @return array|null
+ */
+ private static function build_modified_date_query_arg( array $input ): ?array {
+ $timestamps = self::resolve_date_range_timestamps( $input, 'modified_after', 'modified_before' );
+
+ if ( null === $timestamps ) {
return null;
}
+ list( $after_timestamp, $before_timestamp ) = $timestamps;
+
$date_query = array(
'column' => 'post_modified_gmt',
'inclusive' => false,
@@ -385,14 +400,47 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
}
/**
- * Prepare a date-time string as a timestamp for second-precision order queries.
+ * Resolve an after/before pair of input fields to UTC timestamps.
+ *
+ * @param array $input Ability input.
+ * @param string $after_field Input key for the lower bound.
+ * @param string $before_field Input key for the upper bound.
+ * @return array{0: int|null, 1: int|null}|null Null when neither field is set, or when a provided value is not a valid date-time.
+ */
+ private static function resolve_date_range_timestamps( array $input, string $after_field, string $before_field ): ?array {
+ $after = isset( $input[ $after_field ] ) && is_string( $input[ $after_field ] ) ? sanitize_text_field( $input[ $after_field ] ) : '';
+ $before = isset( $input[ $before_field ] ) && is_string( $input[ $before_field ] ) ? sanitize_text_field( $input[ $before_field ] ) : '';
+
+ if ( '' === $after && '' === $before ) {
+ return null;
+ }
+
+ $after_timestamp = '' !== $after ? self::prepare_date_time_for_query( $after ) : null;
+ $before_timestamp = '' !== $before ? self::prepare_date_time_for_query( $before ) : null;
+
+ if (
+ ( '' !== $after && null === $after_timestamp )
+ || ( '' !== $before && null === $before_timestamp )
+ ) {
+ return null;
+ }
+
+ return array( $after_timestamp, $before_timestamp );
+ }
+
+ /**
+ * Parse a date-time string into a UTC timestamp for second-precision order queries.
+ *
+ * An explicit offset in the value (Z or ±HH:MM) is honoured; a value without one is read
+ * in the store timezone. This covers every date-time format the input schema accepts and
+ * applies the store offset only when the value omits its own.
*
* @param string $date_time Date-time string.
- * @return int|null
+ * @return int|null Null when the value is not a valid date-time.
*/
private static function prepare_date_time_for_query( string $date_time ): ?int {
try {
- return wc_string_to_datetime( $date_time )->getTimestamp();
+ return ( new \DateTimeImmutable( $date_time, wp_timezone() ) )->getTimestamp();
} catch ( \Exception $exception ) {
return null;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index 99266929d32..ccc92e34100 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -2362,6 +2362,90 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertNotContains( $old_order->get_id(), $ids );
}
+ /**
+ * @testdox Should apply date_after and date_before with a time in the store timezone.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_filters_created_date_in_store_timezone( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+
+ $email = wp_unique_id( 'abilities-order-created-timezone-' ) . '@example.com';
+ $early = $this->create_order_for_query_sorting( $email, '2025-07-15T09:00:00', '2025-07-15T09:00:00' );
+ $late = $this->create_order_for_query_sorting( $email, '2025-07-15T11:00:00', '2025-07-15T11:00:00' );
+
+ $after = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_after' => '2025-07-15T10:00:00',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $after );
+ $this->assertSame( array( $late->get_id() ), array_column( $after['orders'], 'id' ), 'date_after 10:00 should return only the order created at 11:00 store time.' );
+
+ $before = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_before' => '2025-07-15T10:00:00',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $before );
+ $this->assertSame( array( $early->get_id() ), array_column( $before['orders'], 'id' ), 'date_before 10:00 should return only the order created at 09:00 store time.' );
+
+ $after_utc = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_after' => '2025-07-15T08:00:00Z',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $after_utc );
+ $this->assertSame( array( $late->get_id() ), array_column( $after_utc['orders'], 'id' ), 'date_after 08:00 UTC (10:00 store time) should return only the order created at 11:00 store time.' );
+
+ $after_millis_utc = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_after' => '2025-07-15T08:00:00.000Z',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $after_millis_utc );
+ $this->assertSame( array( $late->get_id() ), array_column( $after_millis_utc['orders'], 'id' ), 'date_after 08:00 UTC with fractional seconds (the form JavaScript toISOString() produces) should match the plain UTC value.' );
+
+ $after_short_offset = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_after' => '2025-07-15T10:00:00+02',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $after_short_offset );
+ $this->assertSame( array( $late->get_id() ), array_column( $after_short_offset['orders'], 'id' ), 'date_after 10:00 with a +02 offset (08:00 UTC) should return only the order created at 11:00 store time.' );
+
+ $range = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'date_after' => '2025-07-15T08:00:00',
+ 'date_before' => '2025-07-15T10:00:00',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $range );
+ $this->assertSame( array( $early->get_id() ), array_column( $range['orders'], 'id' ), 'date_after 08:00 together with date_before 10:00 store time should return only the order created at 09:00.' );
+ }
+
/**
* @testdox Should filter modified_before with time precision across order storage engines.
*
@@ -2390,6 +2474,36 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->assertNotContains( $late->get_id(), $ids );
}
+ /**
+ * @testdox Should apply modified_before with a UTC offset in the store timezone.
+ *
+ * @dataProvider provider_order_storage_engines
+ *
+ * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+ */
+ public function test_orders_query_filters_modified_date_in_store_timezone( bool $hpos_enabled ): void {
+ $this->set_order_storage_for_test( $hpos_enabled );
+
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+
+ $email = wp_unique_id( 'abilities-order-modified-timezone-' ) . '@example.com';
+ $early = $this->create_order_for_query_sorting( $email, '2025-07-15T09:00:00', '2025-07-15T10:00:00' );
+ $late = $this->create_order_for_query_sorting( $email, '2025-07-15T09:00:00', '2025-07-15T12:00:00' );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute(
+ array(
+ 'billing_email' => $email,
+ 'modified_before' => '2025-07-15T09:00:00.000Z',
+ 'per_page' => 2,
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $ids = array_column( $result['orders'], 'id' );
+ $this->assertContains( $early->get_id(), $ids, 'modified_before 09:00 UTC with fractional seconds should return the order modified at 10:00 store time (08:00 UTC).' );
+ $this->assertNotContains( $late->get_id(), $ids, 'modified_before 09:00 UTC with fractional seconds should exclude the order modified at 12:00 store time (10:00 UTC).' );
+ }
+
/**
* @testdox Should change order status and surface failure when status update fails.
*/