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