Commit 6d2c2ddd5a7 for woocommerce
commit 6d2c2ddd5a77e878b9a402fb8b5c8081cf1cb537
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Wed Apr 29 20:47:39 2026 +0300
Store fulfillment datetimes in UTC and render in site timezone (#64260)
diff --git a/plugins/woocommerce/changelog/64260-wooplug-6528-check-fulfillment-datetime-storage-and-ui-timezone-handling b/plugins/woocommerce/changelog/64260-wooplug-6528-check-fulfillment-datetime-storage-and-ui-timezone-handling
new file mode 100644
index 00000000000..a4437509fb5
--- /dev/null
+++ b/plugins/woocommerce/changelog/64260-wooplug-6528-check-fulfillment-datetime-storage-and-ui-timezone-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store fulfillment datetimes as UTC and return them from the REST API as ISO 8601 with an explicit `Z` suffix so they render correctly on non-UTC sites.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/types.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/types.ts
index f16dc60a7ad..8ef83692f7a 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/types.ts
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments/data/types.ts
@@ -160,7 +160,10 @@ export interface Fulfillment {
entity_id: string;
status: string;
is_fulfilled: boolean;
- date_updated?: Date;
+ // ISO 8601 UTC string with 'Z' suffix (e.g. "2026-04-20T17:00:00Z").
+ date_updated?: string | null;
+ // ISO 8601 UTC string with 'Z' suffix (e.g. "2026-04-20T17:00:00Z").
+ date_deleted?: string | null;
meta_data: MetaDatum[];
}
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
index 94d5bb29d7b..6831d6173ae 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStore.php
@@ -153,7 +153,8 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
throw new \Exception( esc_html__( 'Fulfillment not found.', 'woocommerce' ) );
}
- $data->set_props( array_diff_key( $fulfillment_data, array( 'fulfillment_id' => true ) ) );
+ // Use set_props_from_storage() so already-UTC date columns are not re-normalized as site-local.
+ $data->set_props_from_storage( array_diff_key( $fulfillment_data, array( 'fulfillment_id' => true ) ) );
$data->set_id( (int) $fulfillment_data['fulfillment_id'] );
$data->read_meta_data( true );
$data->set_object_read( true );
@@ -304,8 +305,13 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
// Soft Delete the fulfillment from the database.
global $wpdb;
- $data_id = $data->get_id();
- $deletion_time = current_time( 'mysql' );
+ $data_id = $data->get_id();
+
+ // Route through the setter so the stored value is normalized to UTC,
+ // then read it back for the direct DB write below.
+ $data->set_date_deleted( current_time( 'mysql' ) );
+ $deletion_time = $data->get_date_deleted();
+
$wpdb->update(
$wpdb->prefix . 'wc_order_fulfillments',
array( 'date_deleted' => $deletion_time ),
@@ -321,8 +327,6 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
if ( $wpdb->last_error ) {
throw new \Exception( esc_html__( 'Failed to delete fulfillment.', 'woocommerce' ) );
}
-
- $data->set_date_deleted( $deletion_time );
$data->apply_changes();
$data->set_object_read( true );
@@ -553,7 +557,8 @@ class FulfillmentsDataStore extends \WC_Data_Store_WP implements \WC_Object_Data
// Set the ID directly after the object is created.
$fulfillment = new Fulfillment();
$fulfillment->set_id( $data['fulfillment_id'] );
- $fulfillment->set_props( $data );
+ // Use set_props_from_storage() so already-UTC date columns are not re-normalized as site-local.
+ $fulfillment->set_props_from_storage( $data );
$fulfillment->apply_changes();
$fulfillment->set_object_read( true );
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
index 31fd8f9def9..88821209a21 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/Fulfillment.php
@@ -284,56 +284,124 @@ class Fulfillment extends \WC_Data {
}
/**
- * Get the date updated.
+ * Get the date updated, as a UTC 'Y-m-d H:i:s' string.
*
- * @return string|null Date updated.
+ * @return string|null Date updated in UTC.
*/
public function get_date_updated(): ?string {
return $this->get_prop( 'date_updated' );
}
/**
- * Set the date updated.
+ * Set the date updated. Input is normalized to UTC before storage.
+ *
+ * Bare MySQL-format strings are interpreted as site-local time (matching
+ * the convention of current_time('mysql')). Strings with an explicit
+ * timezone designator (Z, +00:00, UTC) are respected.
+ *
+ * @since 10.1.0
+ * @since 10.8.0 Input is normalized to UTC before storage.
*
* @param string|null $date_updated Date updated.
*/
public function set_date_updated( ?string $date_updated ): void {
- $this->set_prop( 'date_updated', $date_updated );
+ $this->set_prop( 'date_updated', $this->normalize_date_to_utc( $date_updated ) );
}
/**
- * Get the date the fulfillment was fulfilled.
+ * Get the date the fulfillment was fulfilled, as a UTC 'Y-m-d H:i:s' string.
*/
public function get_date_fulfilled(): ?string {
return $this->meta_exists( '_date_fulfilled' ) ? $this->get_meta( '_date_fulfilled', true ) : null;
}
/**
- * Set the date the fulfillment was fulfilled.
+ * Set the date the fulfillment was fulfilled. Input is normalized to UTC.
*
- * @param string $date_fulfilled Date fulfilled.
+ * @since 10.1.0
+ * @since 10.8.0 Input is normalized to UTC before storage.
+ *
+ * @param string $date_fulfilled Date fulfilled. See set_date_updated() for accepted formats.
*/
public function set_date_fulfilled( string $date_fulfilled ): void {
- $this->add_meta_data( '_date_fulfilled', $date_fulfilled, true );
+ $normalized = $this->normalize_date_to_utc( $date_fulfilled );
+ if ( null !== $normalized ) {
+ $this->add_meta_data( '_date_fulfilled', $normalized, true );
+ }
}
/**
- * Get the date deleted.
+ * Get the date deleted, as a UTC 'Y-m-d H:i:s' string.
*
- * @return string|null Date deleted.
+ * @return string|null Date deleted in UTC.
*/
public function get_date_deleted(): ?string {
return $this->get_prop( 'date_deleted' );
}
/**
- * Set the date deleted.
+ * Set the date deleted. Input is normalized to UTC.
*
- * @param string|null $date_deleted Date deleted.
+ * @since 10.1.0
+ * @since 10.8.0 Input is normalized to UTC before storage.
+ *
+ * @param string|null $date_deleted Date deleted. See set_date_updated() for accepted formats.
* @return void
*/
public function set_date_deleted( ?string $date_deleted ): void {
- $this->set_prop( 'date_deleted', $date_deleted );
+ $this->set_prop( 'date_deleted', $this->normalize_date_to_utc( $date_deleted ) );
+ }
+
+ /**
+ * Normalize a date input to a UTC 'Y-m-d H:i:s' string.
+ *
+ * Bare MySQL-format strings are interpreted as site-local time (matching
+ * the convention of current_time('mysql')). Strings that include an
+ * explicit timezone designator (Z, numeric offset, or named zone) are
+ * respected as-is.
+ *
+ * @since 10.8.0
+ * @param string|null $date Date input.
+ * @return string|null UTC datetime string, or null for empty/invalid input.
+ */
+ private function normalize_date_to_utc( ?string $date ): ?string {
+ $date = null === $date ? null : trim( $date );
+ if ( null === $date || '' === $date ) {
+ return null;
+ }
+ try {
+ // The second DateTimeZone is used only when the string has no explicit zone.
+ $datetime = new \DateTime( $date, wp_timezone() );
+ // DateTime silently normalizes invalid calendar dates (e.g. Feb 30 -> Mar 2);
+ // reject those so callers don't persist a different date than the user supplied.
+ $parse_errors = \DateTime::getLastErrors();
+ if ( false !== $parse_errors && ( $parse_errors['warning_count'] > 0 || $parse_errors['error_count'] > 0 ) ) {
+ return null;
+ }
+ $datetime->setTimezone( new \DateTimeZone( 'UTC' ) );
+ return $datetime->format( 'Y-m-d H:i:s' );
+ } catch ( \Exception $e ) {
+ return null;
+ }
+ }
+
+ /**
+ * Set props from a raw storage row, skipping setter-level normalization.
+ *
+ * DB values are already stored in UTC, so they must not be re-normalized
+ * by set_date_*() setters (which would treat them as site-local input).
+ *
+ * @internal For use by the fulfillment data store only.
+ * @since 10.8.0
+ * @param array<string, mixed> $props Prop values keyed by prop name.
+ * @return void
+ */
+ public function set_props_from_storage( array $props ): void {
+ foreach ( $props as $key => $value ) {
+ if ( array_key_exists( $key, $this->data ) ) {
+ $this->set_prop( $key, $value );
+ }
+ }
}
/**
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
index ef2f6cd95d0..b91cbcf22b9 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentsRenderer.php
@@ -347,19 +347,17 @@ class FulfillmentsRenderer {
<tr>
<th class="woocommerce-table__shipment-info shipment-info" style="font-weight: normal;">
<?php
+ // Use the UTC fulfilled date when available; fall back to date_updated.
+ $shipment_date_utc = $fulfillment->get_date_fulfilled()
+ ?? $fulfillment->get_date_updated();
+ // Append ' UTC' so strtotime treats the stored value as UTC, then render in the site's timezone.
+ $shipment_timestamp = $shipment_date_utc ? strtotime( $shipment_date_utc . ' UTC' ) : false;
+ $shipment_date_local = false !== $shipment_timestamp ? wp_date( 'F j, Y', $shipment_timestamp ) : '';
printf(
/* translators: %1$s is the shipment index, %2$s is the shipment date */
wp_kses( __( '<b>Shipment %1$s</b> was shipped on <b>%2$s</b>', 'woocommerce' ), 'b' ),
intval( $index ) + 1,
- esc_html(
- gmdate(
- 'F j, Y',
- strtotime(
- $fulfillment->get_date_fulfilled() // Get the fulfilled date.
- ?? $fulfillment->get_date_updated() // Fallback to the updated date if fulfilled date is not set.
- )
- )
- )
+ esc_html( (string) $shipment_date_local )
);
?>
</th>
diff --git a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
index 450421301e4..b56953aa635 100644
--- a/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
+++ b/plugins/woocommerce/src/Admin/Features/Fulfillments/OrderFulfillmentsRestController.php
@@ -207,6 +207,9 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
/**
* Get the fulfillments for the order.
*
+ * @since 10.1.0
+ * @since 10.8.0 Date fields are returned as ISO 8601 UTC with 'Z' suffix.
+ *
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The fulfillments for the order, or an error if the request fails.
@@ -235,8 +238,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
// Return the fulfillments.
return new WP_REST_Response(
array_map(
- function ( $fulfillment ) {
- return $fulfillment->get_raw_data(); },
+ fn( $fulfillment ) => $this->prepare_fulfillment_response_data( $fulfillment->get_raw_data() ),
$fulfillments
),
WP_Http::OK
@@ -246,6 +248,10 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
/**
* Create a new fulfillment with the given data for the order.
*
+ * @since 10.1.0
+ * @since 10.8.0 Date fields in the response are returned as ISO 8601 UTC with 'Z' suffix.
+ * @since 10.8.0 Meta data from the request is normalized via MetaDataUtil.
+ *
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The created fulfillment, or an error if the request fails.
@@ -269,8 +275,11 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
// Create a new fulfillment.
try {
$fulfillment = new Fulfillment();
- $fulfillment->set_props( $request->get_json_params() );
- $fulfillment->set_meta_data( $request->get_json_params()['meta_data'] );
+ $params = $request->get_json_params();
+ $fulfillment->set_props( $params );
+ if ( isset( $params['meta_data'] ) ) {
+ $this->apply_request_meta_data( $params['meta_data'], $fulfillment );
+ }
$fulfillment->set_entity_type( WC_Order::class );
$fulfillment->set_entity_id( "$order_id" );
@@ -313,12 +322,15 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
);
}
- return new WP_REST_Response( $fulfillment->get_raw_data(), WP_Http::CREATED );
+ return new WP_REST_Response( $this->prepare_fulfillment_response_data( $fulfillment->get_raw_data() ), WP_Http::CREATED );
}
/**
* Get a specific fulfillment for the order.
*
+ * @since 10.1.0
+ * @since 10.8.0 Date fields in the response are returned as ISO 8601 UTC with 'Z' suffix.
+ *
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The fulfillment for the order, or an error if the request fails.
@@ -348,7 +360,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
return new WP_REST_Response(
- $fulfillment->get_raw_data(),
+ $this->prepare_fulfillment_response_data( $fulfillment->get_raw_data() ),
WP_Http::OK
);
}
@@ -356,6 +368,9 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
/**
* Update a specific fulfillment for the order.
*
+ * @since 10.1.0
+ * @since 10.8.0 Date fields in the response are returned as ISO 8601 UTC with 'Z' suffix.
+ *
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response The updated fulfillment, or an error if the request fails.
@@ -390,8 +405,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
if ( isset( $request->get_json_params()['meta_data'] ) ) {
$meta_data = $request->get_json_params()['meta_data'];
- $normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
- MetaDataUtil::update( $meta_data, $fulfillment, 0 );
+ $normalized_keys = $this->apply_request_meta_data( $meta_data, $fulfillment );
// Remove meta keys not in the request. Skip if all entries were malformed
// (non-empty input but no valid keys), to avoid accidental data loss.
@@ -462,7 +476,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
return new WP_REST_Response(
- $fulfillment->get_raw_data(),
+ $this->prepare_fulfillment_response_data( $fulfillment->get_raw_data() ),
WP_Http::OK
);
}
@@ -549,7 +563,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
return new WP_REST_Response(
- $fulfillment->get_raw_meta_data(),
+ $this->prepare_meta_data_for_response( $fulfillment->get_raw_meta_data() ),
WP_Http::OK
);
}
@@ -572,8 +586,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
// Update the meta data keys that exist in the request.
$meta_data = $request->get_json_params()['meta_data'];
- $normalized_keys = is_array( $meta_data ) ? array_column( MetaDataUtil::normalize( $meta_data, 0 ), 'key' ) : array();
- MetaDataUtil::update( $meta_data, $fulfillment, 0 );
+ $normalized_keys = $this->apply_request_meta_data( $meta_data, $fulfillment );
// Remove meta keys not in the request. Skip if all entries were malformed
// (non-empty input but no valid keys), to avoid accidental data loss.
@@ -601,7 +614,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
return new WP_REST_Response(
- $fulfillment->get_raw_meta_data(),
+ $this->prepare_meta_data_for_response( $fulfillment->get_raw_meta_data() ),
WP_Http::OK
);
}
@@ -640,7 +653,7 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
}
return new WP_REST_Response(
- $fulfillment->get_raw_meta_data(),
+ $this->prepare_meta_data_for_response( $fulfillment->get_raw_meta_data() ),
WP_Http::OK
);
}
@@ -1310,4 +1323,86 @@ class OrderFulfillmentsRestController extends RestApiControllerBase {
$is_custom
);
}
+
+ /**
+ * Apply request-supplied meta data to a fulfillment, routing `_date_fulfilled`
+ * through {@see Fulfillment::set_date_fulfilled()} so the UTC normalization
+ * contract is preserved regardless of input path.
+ *
+ * @since 10.8.0
+ *
+ * @param mixed $meta_data Raw meta data from the request (non-array values are ignored).
+ * @param Fulfillment $fulfillment Target fulfillment.
+ * @return array<int, string> Normalized meta keys present in the request.
+ */
+ private function apply_request_meta_data( $meta_data, Fulfillment $fulfillment ): array {
+ if ( ! is_array( $meta_data ) ) {
+ return array();
+ }
+
+ $normalized = MetaDataUtil::normalize( $meta_data, 0 );
+ foreach ( $normalized as $meta ) {
+ if ( '_date_fulfilled' === $meta['key'] && is_string( $meta['value'] ) ) {
+ $fulfillment->set_date_fulfilled( $meta['value'] );
+ continue;
+ }
+ $fulfillment->update_meta_data( $meta['key'], $meta['value'], $meta['id'] );
+ }
+
+ return array_column( $normalized, 'key' );
+ }
+
+ /**
+ * Format the fulfillment raw data for a REST response by converting every
+ * UTC-stored datetime field into an ISO 8601 string with explicit 'Z' suffix.
+ *
+ * @since 10.8.0
+ * @param array<string, mixed> $raw_data The fulfillment raw data.
+ * @return array<string, mixed>
+ */
+ private function prepare_fulfillment_response_data( array $raw_data ): array {
+ $raw_data['date_updated'] = $this->format_utc_date_iso8601( $raw_data['date_updated'] ?? null );
+ $raw_data['date_deleted'] = $this->format_utc_date_iso8601( $raw_data['date_deleted'] ?? null );
+
+ if ( isset( $raw_data['meta_data'] ) && is_array( $raw_data['meta_data'] ) ) {
+ $raw_data['meta_data'] = $this->prepare_meta_data_for_response( $raw_data['meta_data'] );
+ }
+
+ return $raw_data;
+ }
+
+ /**
+ * Format `_date_fulfilled` entries in a meta data array as ISO 8601 with 'Z'
+ * suffix. All other entries pass through unchanged.
+ *
+ * @since 10.8.0
+ *
+ * @param array<int, mixed> $meta_data Raw meta data array.
+ * @return array<int, mixed>
+ */
+ private function prepare_meta_data_for_response( array $meta_data ): array {
+ foreach ( $meta_data as &$meta ) {
+ if ( is_array( $meta ) && isset( $meta['key'], $meta['value'] ) && '_date_fulfilled' === $meta['key'] && is_string( $meta['value'] ) ) {
+ $meta['value'] = $this->format_utc_date_iso8601( $meta['value'] );
+ }
+ }
+ unset( $meta );
+
+ return $meta_data;
+ }
+
+ /**
+ * Convert a UTC 'Y-m-d H:i:s' datetime string to ISO 8601 with 'Z' suffix.
+ *
+ * @since 10.8.0
+ * @param string|null $date UTC datetime string.
+ * @return string|null ISO 8601 string with 'Z' suffix, or null for empty input.
+ */
+ private function format_utc_date_iso8601( ?string $date ): ?string {
+ if ( null === $date || '' === $date ) {
+ return null;
+ }
+ $formatted = wc_rest_prepare_date_response( $date );
+ return null === $formatted ? null : $formatted . 'Z';
+ }
}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php
index fedcc083efd..898072e68f0 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Schema/FulfillmentSchema.php
@@ -134,6 +134,7 @@ class FulfillmentSchema extends AbstractSchema {
* @return array The item response.
*/
public function get_item_response( $fulfillment, WP_REST_Request $request, array $include_fields = array() ): array {
+ $date_updated = $fulfillment->get_date_updated();
$date_deleted = $fulfillment->get_date_deleted();
return array(
@@ -142,9 +143,45 @@ class FulfillmentSchema extends AbstractSchema {
'entity_id' => (string) $fulfillment->get_entity_id(),
'status' => $fulfillment->get_status(),
'is_fulfilled' => $fulfillment->get_is_fulfilled(),
- 'date_updated' => wc_rest_prepare_date_response( $fulfillment->get_date_updated() ),
- 'date_deleted' => $date_deleted ? wc_rest_prepare_date_response( $date_deleted ) : null,
- 'meta_data' => $fulfillment->get_meta_data(),
+ 'date_updated' => $this->format_utc_iso8601( $date_updated ),
+ 'date_deleted' => $this->format_utc_iso8601( $date_deleted ),
+ 'meta_data' => $this->prepare_meta_data_for_response( $fulfillment->get_raw_meta_data() ),
);
}
+
+ /**
+ * Format a UTC 'Y-m-d H:i:s' string as ISO 8601 with explicit 'Z' suffix.
+ *
+ * @since 10.8.0
+ * @param string|null $date UTC datetime string.
+ * @return string|null
+ */
+ private function format_utc_iso8601( ?string $date ): ?string {
+ if ( null === $date || '' === $date ) {
+ return null;
+ }
+ $formatted = wc_rest_prepare_date_response( $date );
+ return null === $formatted ? null : $formatted . 'Z';
+ }
+
+ /**
+ * Format `_date_fulfilled` entries in a meta data array as ISO 8601 with 'Z'
+ * suffix so V4 clients see the same UTC contract as V3 instead of the raw
+ * 'Y-m-d H:i:s' storage form. Other entries pass through unchanged.
+ *
+ * @since 10.8.0
+ *
+ * @param array<int, mixed> $meta_data Raw meta data array.
+ * @return array<int, mixed>
+ */
+ private function prepare_meta_data_for_response( array $meta_data ): array {
+ foreach ( $meta_data as &$meta ) {
+ if ( is_array( $meta ) && isset( $meta['key'], $meta['value'] ) && '_date_fulfilled' === $meta['key'] && is_string( $meta['value'] ) ) {
+ $meta['value'] = $this->format_utc_iso8601( $meta['value'] );
+ }
+ }
+ unset( $meta );
+
+ return $meta_data;
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
index c198ceaa8d9..4b46332dd6e 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/DataStore/FulfillmentsDataStoreTest.php
@@ -141,17 +141,120 @@ class FulfillmentsDataStoreTest extends \WC_Unit_Test_Case {
$this->assertNotNull( $fulfillment->get_date_fulfilled() );
// Set a known fixed date_fulfilled to make the assertion deterministic.
- $fixed_date = '2025-01-15 10:30:00';
- $fulfillment->set_date_fulfilled( $fixed_date );
+ // Pass an explicit UTC marker so the setter's timezone normalization is a no-op.
+ $fulfillment->set_date_fulfilled( '2025-01-15 10:30:00 UTC' );
$fulfillment->set_entity_id( '456' );
$this->data_store->update( $fulfillment );
- // Verify in-memory value is preserved.
- $this->assertEquals( $fixed_date, $fulfillment->get_date_fulfilled() );
+ // Verify in-memory value is the normalized UTC 'Y-m-d H:i:s' form.
+ $this->assertEquals( '2025-01-15 10:30:00', $fulfillment->get_date_fulfilled() );
// Verify persisted value matches after reloading from DB.
$read_fulfillment = new Fulfillment( $fulfillment->get_id() );
- $this->assertEquals( $fixed_date, $read_fulfillment->get_date_fulfilled() );
+ $this->assertEquals( '2025-01-15 10:30:00', $read_fulfillment->get_date_fulfilled() );
+ }
+
+ /**
+ * Tests that setters normalize datetime inputs to UTC regardless of the site timezone.
+ */
+ public function test_setters_normalize_to_utc_under_non_utc_site_timezone() {
+ $original_timezone = get_option( 'timezone_string' );
+ $original_offset = get_option( 'gmt_offset' );
+
+ update_option( 'timezone_string', 'America/Los_Angeles' );
+ update_option( 'gmt_offset', '' );
+
+ try {
+ $fulfillment = new Fulfillment();
+
+ // A bare MySQL string is interpreted as site-local (LA, UTC-8 in January).
+ $fulfillment->set_date_fulfilled( '2025-01-15 10:30:00' );
+ $this->assertSame( '2025-01-15 18:30:00', $fulfillment->get_date_fulfilled() );
+
+ // An ISO string with explicit Z is respected as UTC.
+ $fulfillment->set_date_fulfilled( '2025-01-15T10:30:00Z' );
+ $this->assertSame( '2025-01-15 10:30:00', $fulfillment->get_date_fulfilled() );
+
+ // The same normalization applies to date_updated and date_deleted setters.
+ $fulfillment->set_date_updated( '2025-01-15 10:30:00' );
+ $this->assertSame( '2025-01-15 18:30:00', $fulfillment->get_date_updated() );
+
+ $fulfillment->set_date_deleted( '2025-01-15T10:30:00+00:00' );
+ $this->assertSame( '2025-01-15 10:30:00', $fulfillment->get_date_deleted() );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ update_option( 'gmt_offset', $original_offset );
+ }//end try
+ }
+
+ /**
+ * Tests that the date setters reject malformed and whitespace-only inputs
+ * instead of silently storing a normalized but unintended value.
+ */
+ public function test_setters_reject_malformed_date_input() {
+ $fulfillment = new Fulfillment();
+
+ // PHP's DateTime would silently roll Feb 30 into March; setter must reject it.
+ $fulfillment->set_date_fulfilled( '2025-02-30 10:00:00' );
+ $this->assertNull( $fulfillment->get_date_fulfilled() );
+
+ // Whitespace-only input must not be parsed as "now".
+ $fulfillment->set_date_updated( ' ' );
+ $this->assertNull( $fulfillment->get_date_updated() );
+
+ $fulfillment->set_date_deleted( 'not a date' );
+ $this->assertNull( $fulfillment->get_date_deleted() );
+ }
+
+ /**
+ * Tests that the data store persists datetimes as UTC even when the site
+ * timezone is not UTC, and that the value survives a DB round-trip unchanged.
+ */
+ public function test_data_store_persists_dates_in_utc_under_non_utc_site_timezone() {
+ $original_timezone = get_option( 'timezone_string' );
+ $original_offset = get_option( 'gmt_offset' );
+
+ update_option( 'timezone_string', 'America/Los_Angeles' );
+ update_option( 'gmt_offset', '' );
+
+ try {
+ $fulfillment = new Fulfillment();
+ $fulfillment->set_entity_type( 'order-fulfillment' );
+ $fulfillment->set_entity_id( '789' );
+ $fulfillment->set_status( 'fulfilled' );
+ $fulfillment->set_items(
+ array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ )
+ );
+
+ $before_utc = gmdate( 'Y-m-d H:i:s' );
+ $this->data_store->create( $fulfillment );
+ $after_utc = gmdate( 'Y-m-d H:i:s' );
+
+ // Persisted date_updated should be a UTC value within the create window.
+ $stored_date_updated = $fulfillment->get_date_updated();
+ $this->assertNotNull( $stored_date_updated );
+ $this->assertGreaterThanOrEqual( $before_utc, $stored_date_updated );
+ $this->assertLessThanOrEqual( $after_utc, $stored_date_updated );
+
+ // Same for date_fulfilled (set automatically on create when is_fulfilled=true).
+ $stored_date_fulfilled = $fulfillment->get_date_fulfilled();
+ $this->assertNotNull( $stored_date_fulfilled );
+ $this->assertGreaterThanOrEqual( $before_utc, $stored_date_fulfilled );
+ $this->assertLessThanOrEqual( $after_utc, $stored_date_fulfilled );
+
+ // Values must survive a DB round-trip unchanged (no re-normalization on read).
+ $read_fulfillment = new Fulfillment( $fulfillment->get_id() );
+ $this->assertSame( $stored_date_updated, $read_fulfillment->get_date_updated() );
+ $this->assertSame( $stored_date_fulfilled, $read_fulfillment->get_date_fulfilled() );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ update_option( 'gmt_offset', $original_offset );
+ }//end try
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
index dfa6f93ffe6..1d2d388abf1 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/OrderFulfillmentsRestControllerTest.php
@@ -2138,4 +2138,144 @@ class OrderFulfillmentsRestControllerTest extends WC_REST_Unit_Test_Case {
remove_action( 'woocommerce_fulfillment_updated_notification', $callback );
wp_set_current_user( 0 );
}
+
+ /**
+ * A `_date_fulfilled` value supplied via meta_data on create must be routed
+ * through the UTC normalization contract (set_date_fulfilled), and the
+ * response must surface it as ISO 8601 with a 'Z' suffix.
+ */
+ public function test_create_fulfillment_normalizes_date_fulfilled_meta_to_utc() {
+ $original_timezone = get_option( 'timezone_string' );
+ $original_offset = get_option( 'gmt_offset' );
+
+ update_option( 'timezone_string', 'America/Los_Angeles' );
+ update_option( 'gmt_offset', '' );
+
+ $date_fulfilled = $this->dispatch_create_with_date_fulfilled_meta( '2025-01-15 10:30:00' );
+
+ update_option( 'timezone_string', $original_timezone );
+ update_option( 'gmt_offset', $original_offset );
+
+ // Bare MySQL string is treated as site-local (LA, UTC-8 in January) and
+ // surfaced as ISO 8601 with explicit 'Z'.
+ $this->assertSame( '2025-01-15T18:30:00Z', $date_fulfilled );
+ }
+
+ /**
+ * Dispatches a create-fulfillment REST request with a `_date_fulfilled` meta
+ * value and returns the value the API surfaces back for that key.
+ *
+ * @param string $date_fulfilled Value to send in `meta_data['_date_fulfilled']`.
+ * @return string|null
+ */
+ private function dispatch_create_with_date_fulfilled_meta( string $date_fulfilled ): ?string {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+
+ // Use is_fulfilled=false so the data store does not unconditionally overwrite
+ // date_fulfilled with current_time() during create; this isolates the test to
+ // the meta-data normalization path under test.
+ wp_set_current_user( 1 );
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => '' . $order->get_id(),
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => false,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_date_fulfilled',
+ 'value' => $date_fulfilled,
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+ $response = $this->server->dispatch( $request );
+ $this->assertEquals( WP_Http::CREATED, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertIsArray( $data );
+ wp_set_current_user( 0 );
+
+ foreach ( $data['meta_data'] as $meta ) {
+ if ( '_date_fulfilled' === $meta['key'] ) {
+ return $meta['value'];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * The /metadata endpoints must format `_date_fulfilled` as ISO 8601 with 'Z'
+ * suffix instead of leaking the raw 'Y-m-d H:i:s' UTC storage form.
+ */
+ public function test_get_fulfillment_meta_formats_date_fulfilled_as_iso8601() {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+
+ wp_set_current_user( 1 );
+
+ // Use is_fulfilled=false so the data store does not overwrite the
+ // _date_fulfilled meta value with current_time() during create.
+ $create = new WP_REST_Request( 'POST', '/wc/v3/orders/' . $order->get_id() . '/fulfillments' );
+ $create->set_header( 'content-type', 'application/json' );
+ $create->set_body(
+ wp_json_encode(
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => '' . $order->get_id(),
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => false,
+ 'meta_data' => array(
+ array(
+ 'id' => 0,
+ 'key' => '_date_fulfilled',
+ 'value' => '2025-01-15T10:30:00Z',
+ ),
+ array(
+ 'id' => 0,
+ 'key' => '_items',
+ 'value' => array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+ $create_response = $this->server->dispatch( $create );
+ $this->assertEquals( WP_Http::CREATED, $create_response->get_status() );
+ $fulfillment_id = $create_response->get_data()['id'];
+
+ $get = new WP_REST_Request( 'GET', '/wc/v3/orders/' . $order->get_id() . '/fulfillments/' . $fulfillment_id . '/metadata' );
+ $get_response = $this->server->dispatch( $get );
+ $this->assertEquals( WP_Http::OK, $get_response->get_status() );
+
+ $get_meta = null;
+ foreach ( $get_response->get_data() as $meta ) {
+ if ( '_date_fulfilled' === $meta['key'] ) {
+ $get_meta = $meta['value'];
+ break;
+ }
+ }
+ $this->assertSame( '2025-01-15T10:30:00Z', $get_meta );
+
+ wp_set_current_user( 0 );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
index 9c1e1ba31ac..d6a8f46fdd3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Fulfillments/ControllerTest.php
@@ -246,6 +246,32 @@ class ControllerTest extends WC_REST_Unit_Test_Case {
$this->assertEquals( $this->test_fulfillment->get_id(), $data['id'] );
}
+ /**
+ * V4 must format `_date_fulfilled` meta as ISO 8601 with 'Z' suffix in
+ * responses, matching V3 and the storage UTC contract — clients should not
+ * see the raw 'Y-m-d H:i:s' form.
+ */
+ public function test_get_fulfillment_formats_date_fulfilled_meta_as_iso8601() {
+ wp_set_current_user( $this->admin_user_id );
+
+ $this->test_fulfillment->set_date_fulfilled( '2025-01-15T10:30:00Z' );
+ $this->test_fulfillment->save();
+
+ $request = new WP_REST_Request( 'GET', '/wc/v4/fulfillments/' . $this->test_fulfillment->get_id() );
+ $response = rest_get_server()->dispatch( $request );
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $date_fulfilled = null;
+ foreach ( $data['meta_data'] as $meta ) {
+ if ( '_date_fulfilled' === $meta['key'] ) {
+ $date_fulfilled = $meta['value'];
+ break;
+ }
+ }
+ $this->assertSame( '2025-01-15T10:30:00Z', $date_fulfilled );
+ }
+
/**
* Test get_fulfillment with invalid ID
*/