Commit d5a7bb34c68 for woocommerce
commit d5a7bb34c68345219996eb4b30138c15d3c165ef
Author: Aarón Fas <yo@aaron.com.es>
Date: Mon Nov 2 17:43:10 2026 +0100
Include timezone offsets in product and order ability dates (#70396)
* Fix timezone offsets in ability output dates
A successful product or order update can look failed when a client rejects its offset-free response dates. Return dates with explicit store and UTC offsets so clients can validate the result and identify the same instant.
Include timezone regressions and the release note with the fix.
* Preserve filtered dates in ability responses
Accept date strings and timestamps supported by the previous formatter. Return null for unsupported values or failed parsing so response formatting does not fail after a product has been saved. Cover valid and invalid filter values through product-update.
* Strengthen ability date regression assertions
Check that response formatting preserves the timezone and timestamp of a filtered date object. Require product date values to parse and round-trip before comparing timestamps, preventing two failed parses from satisfying the assertion.
* Simplify the order date test fixture
Create only the order needed by the date assertions, avoiding unused products and shipping items. Preserve the explicit modified-date setup for HPOS and legacy storage and add the required test description.
* Document the ability date formatter introduction
Add the required since annotation for the new protected helper using the target WooCommerce version.
* Return null for non-date ability filter values
Limit response formatting to WC_DateTime objects instead of interpreting scalar values from date filters. Keep the defensive guard to prevent errors after saving, and assert null output for string and timestamp filter results.
* Assert exact ability modification dates
Use a deterministic product modification date and explicit UTC expectations across all timezone cases. Catch incorrect timestamps and local fields accidentally formatted in UTC, while retaining parsing and instant-equivalence checks.
diff --git a/plugins/woocommerce/changelog/fix-ability-output-date-offsets b/plugins/woocommerce/changelog/fix-ability-output-date-offsets
new file mode 100644
index 00000000000..2323b0ffb08
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-ability-output-date-offsets
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return timezone offsets in product and order ability dates so clients can validate successful results.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
index 4246a92d4c3..349eda893e1 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/DomainAbilityHelpers.php
@@ -14,6 +14,25 @@ defined( 'ABSPATH' ) || exit;
*/
trait DomainAbilityHelpers {
+ /**
+ * Format an ability date with its store or UTC offset without changing the object.
+ *
+ * @since 11.4.0
+ *
+ * @param mixed $date Date to format.
+ * @param bool $utc Whether to use UTC instead of the store timezone.
+ * @return string|null
+ */
+ protected static function format_date_for_response( $date, bool $utc = true ): ?string {
+ if ( ! $date instanceof \WC_DateTime ) {
+ return null;
+ }
+
+ $date = clone $date;
+ $date->setTimezone( $utc ? new \DateTimeZone( 'UTC' ) : wp_timezone() );
+ return $date->format( DATE_ATOM );
+ }
+
/**
* Get a collection output schema.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index fb3137600eb..1be100ff5ea 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -295,10 +295,10 @@ trait OrderAbilityTrait {
'billing_email' => '' === $billing_email ? null : $billing_email,
'payment_method' => $order->get_payment_method(),
'payment_method_title' => $order->get_payment_method_title(),
- 'date_created' => wc_rest_prepare_date_response( $order->get_date_created(), false ),
- 'date_created_gmt' => wc_rest_prepare_date_response( $order->get_date_created() ),
- 'date_modified' => wc_rest_prepare_date_response( $order->get_date_modified(), false ),
- 'date_modified_gmt' => wc_rest_prepare_date_response( $order->get_date_modified() ),
+ 'date_created' => self::format_date_for_response( $order->get_date_created(), false ),
+ 'date_created_gmt' => self::format_date_for_response( $order->get_date_created() ),
+ 'date_modified' => self::format_date_for_response( $order->get_date_modified(), false ),
+ 'date_modified_gmt' => self::format_date_for_response( $order->get_date_modified() ),
);
if ( $include_line_items ) {
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index d3d43b4bc44..b55dbb2128b 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -809,10 +809,10 @@ trait ProductAbilityTrait {
'external_url' => $product instanceof \WC_Product_External ? $product->get_product_url() : null,
'button_text' => $product instanceof \WC_Product_External ? $product->get_button_text() : null,
'grouped_products' => $product instanceof \WC_Product_Grouped ? array_map( 'absint', $product->get_children( 'edit' ) ) : array(),
- 'date_created' => wc_rest_prepare_date_response( $product->get_date_created(), false ),
- 'date_created_gmt' => wc_rest_prepare_date_response( $product->get_date_created() ),
- 'date_modified' => wc_rest_prepare_date_response( $product->get_date_modified(), false ),
- 'date_modified_gmt' => wc_rest_prepare_date_response( $product->get_date_modified() ),
+ 'date_created' => self::format_date_for_response( $product->get_date_created(), false ),
+ 'date_created_gmt' => self::format_date_for_response( $product->get_date_created() ),
+ 'date_modified' => self::format_date_for_response( $product->get_date_modified(), false ),
+ 'date_modified_gmt' => self::format_date_for_response( $product->get_date_modified() ),
);
return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index d70599d48a9..47969bcb7b7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -1766,6 +1766,158 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should return null for non-date filter values without failing a product update.
+ * @testWith ["2026-07-15T10:00:00"]
+ * [1784109600]
+ * ["1784109600"]
+ * ["invalid date"]
+ * [[]]
+ * [false]
+ * [null]
+ * @param mixed $date Filtered date.
+ */
+ public function test_product_output_handles_filtered_dates( $date ): void {
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+ $product = \WC_Helper_Product::create_simple_product();
+ $this->created_product_ids[] = $product->get_id();
+ add_filter(
+ 'woocommerce_product_get_date_created',
+ static function () use ( $date ) {
+ return $date;
+ }
+ );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Filtered date update',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertNull( $result['product']['date_created'] );
+ $this->assertNull( $result['product']['date_created_gmt'] );
+ $this->assertSame( 'Filtered date update', wc_get_product( $product->get_id() )->get_name() );
+ }
+
+ /**
+ * @testdox Should preserve the timezone and timestamp of filtered date objects.
+ */
+ public function test_order_output_preserves_filtered_date_object(): void {
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+ $order = wc_create_order();
+ $this->created_order_ids[] = $order->get_id();
+ $date = new \WC_DateTime( '2026-07-15T12:00:00', new \DateTimeZone( 'Asia/Kathmandu' ) );
+ $timestamp = $date->getTimestamp();
+ add_filter(
+ 'woocommerce_order_get_date_created',
+ static function () use ( $date ) {
+ return $date;
+ }
+ );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => $order->get_id() ) );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( '2026-07-15T08:15:00+02:00', $result['orders'][0]['date_created'] );
+ $this->assertSame( '2026-07-15T06:15:00+00:00', $result['orders'][0]['date_created_gmt'] );
+ $this->assertSame( 'Asia/Kathmandu', $date->getTimezone()->getName() );
+ $this->assertSame( $timestamp, $date->getTimestamp() );
+ }
+
+ /**
+ * @testdox Should return valid product dates with matching store and UTC timestamps.
+ * @dataProvider provider_output_date_timezones
+ * @param string $timezone Store timezone.
+ * @param string $created Creation date in store time.
+ * @param string $expected Expected date with offset.
+ * @param string $expected_gmt Expected UTC date.
+ */
+ public function test_product_output_dates_have_offsets( string $timezone, string $created, string $expected, string $expected_gmt ): void {
+ update_option( 'timezone_string', $timezone );
+ update_option( 'gmt_offset', 5.75 );
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->set_date_created( $created );
+ $product->save();
+ $this->created_product_ids[] = $product->get_id();
+ $modified = new \WC_DateTime( $created, wp_timezone() );
+ add_filter(
+ 'woocommerce_product_get_date_modified',
+ static function () use ( $modified ) {
+ return $modified;
+ }
+ );
+
+ $result = wp_get_ability( 'woocommerce/product-update' )->execute(
+ array(
+ 'id' => $product->get_id(),
+ 'name' => 'Date output regression',
+ )
+ );
+
+ $this->assertNotWPError( $result );
+ $this->assertSame( 'Date output regression', wc_get_product( $product->get_id() )->get_name() );
+ foreach ( array( 'date_created', 'date_modified' ) as $field ) {
+ $date = $result['product'][ $field ];
+ $date_gmt = $result['product'][ $field . '_gmt' ];
+ $parsed = \DateTimeImmutable::createFromFormat( DATE_ATOM, $date );
+ $parsed_gmt = \DateTimeImmutable::createFromFormat( DATE_ATOM, $date_gmt );
+
+ $this->assertSame( $expected, $date );
+ $this->assertSame( $expected_gmt, $date_gmt );
+ $this->assertInstanceOf( \DateTimeImmutable::class, $parsed );
+ $this->assertInstanceOf( \DateTimeImmutable::class, $parsed_gmt );
+ $this->assertSame( $date, $parsed->format( DATE_ATOM ) );
+ $this->assertSame( $date_gmt, $parsed_gmt->format( DATE_ATOM ) );
+ $this->assertStringEndsWith( '+00:00', $date_gmt );
+ $this->assertSame( $parsed->getTimestamp(), $parsed_gmt->getTimestamp() );
+ }
+ }
+
+ /**
+ * @testdox Should return order dates with matching store and UTC timestamps.
+ * @dataProvider provider_output_date_timezones
+ * @param string $timezone Store timezone.
+ * @param string $created Creation date in store time.
+ * @param string $expected Expected date with offset.
+ * @param string $expected_gmt Expected UTC date.
+ */
+ public function test_order_output_dates_have_offsets( string $timezone, string $created, string $expected, string $expected_gmt ): void {
+ update_option( 'timezone_string', $timezone );
+ update_option( 'gmt_offset', 5.75 );
+ $order = wc_create_order();
+ $order->set_date_created( $created );
+ $order->set_date_modified( $created );
+ $order->save();
+ $this->created_order_ids[] = $order->get_id();
+ $this->set_order_modified_date_for_query_test( $order, $created );
+
+ $result = wp_get_ability( 'woocommerce/orders-query' )->execute( array( 'id' => $order->get_id() ) );
+
+ $this->assertNotWPError( $result );
+ foreach ( array( 'date_created', 'date_modified' ) as $field ) {
+ $this->assertSame( $expected, $result['orders'][0][ $field ] );
+ $this->assertSame( $expected_gmt, $result['orders'][0][ $field . '_gmt' ] );
+ $this->assertSame( strtotime( $expected ), strtotime( $result['orders'][0][ $field . '_gmt' ] ) );
+ }
+ }
+
+ /**
+ * Store timezones and dates, including daylight saving and fractional offsets.
+ *
+ * @return array
+ */
+ public function provider_output_date_timezones(): array {
+ return array(
+ 'UTC' => array( 'UTC', '2026-07-15T12:00:00', '2026-07-15T12:00:00+00:00', '2026-07-15T12:00:00+00:00' ),
+ 'summer' => array( 'Europe/Amsterdam', '2026-07-15T12:00:00', '2026-07-15T12:00:00+02:00', '2026-07-15T10:00:00+00:00' ),
+ 'winter' => array( 'Europe/Amsterdam', '2026-01-15T12:00:00', '2026-01-15T12:00:00+01:00', '2026-01-15T11:00:00+00:00' ),
+ 'negative' => array( 'America/New_York', '2026-07-15T12:00:00', '2026-07-15T12:00:00-04:00', '2026-07-15T16:00:00+00:00' ),
+ 'fractional' => array( '', '2026-07-15T12:00:00', '2026-07-15T12:00:00+05:45', '2026-07-15T06:15:00+00:00' ),
+ );
+ }
+
/**
* @testdox Should update product properties and reflect changes in the response.
*/