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