Commit 45a69aefd6e for woocommerce

commit 45a69aefd6e9f2cedd9de4d683f1499a6875007e
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Fri Sep 18 11:51:35 2026 +0300

    Fix HPOS migration leaving orders without a created date when post_date_gmt is zero (#68772)

    * Fall back to local post dates in HPOS migration when GMT dates are zero

    * Keep post dates when backfilling an order that has no created date

    * Read the local post date when the GMT one is the zero date

    * Keep post dates when creating the backup post for an order without a created date

    * Include pending orders without an updated date in unpaid order cancellation

    * Add the since tag to the post date fallback helper

    * Fill HPOS order dates left empty by earlier migrations from their posts on update

    * Repair cleaned-up orders too, drop their cached objects and queue their Analytics import

    * Treat a null source date like an empty one in the migrator

    * Write repaired HPOS dates only while still empty and flush repaired ids on a failed batch

    * Skip queueing the Analytics import for repaired orders when Analytics is disabled

    * Guard the import hook lookup for PHPStan

    * Stop the HPOS date repair when its progress cursor cannot be saved

    * Only move the HPOS date repair cursor forward so concurrent runs do not stop it

    * Queue the Analytics import for repaired orders without the per-order duplicate search

    * Judge unpaid orders without an updated date by their created date

    * Say in the changelog that orders saved since the migration are not repaired

    * Shorten the changelog entry to one user-facing line

    * Keep storing the zero date in the HPOS migration when a post has no date to fall back to

    * Restore the unpaid order query now that the migration stores no NULL dates

    * Apply the HPOS date fallback in the post column path only, leaving the shared migrator validation unchanged

diff --git a/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates b/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates
new file mode 100644
index 00000000000..0163552b161
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix orders losing their created date when migrating to HPOS from posts with an empty GMT date.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b113382eddc..8a4db694b76 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -360,6 +360,9 @@ class WC_Install {
 			'wc_update_11202_reset_refund_returning_customer_markers',
 			'wc_update_11203_normalize_stock_notification_emails',
 		),
+		'11.3.0'   => array(
+			'wc_update_1130_repair_hpos_order_dates_from_posts',
+		),
 	);

 	/**
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index 03593403e73..9b602afb4e2 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -153,8 +153,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 			$order,
 			array(
 				'parent_id'     => $post_object->post_parent,
-				'date_created'  => $this->string_to_timestamp( $post_object->post_date_gmt ),
-				'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
+				'date_created'  => $this->post_date_to_timestamp( $post_object->post_date_gmt, $post_object->post_date ),
+				'date_modified' => $this->post_date_to_timestamp( $post_object->post_modified_gmt, $post_object->post_modified ),
 				'status'        => $post_object->post_status,
 			)
 		);
@@ -426,6 +426,26 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 		return wc_generate_order_key();
 	}

+	/**
+	 * Convert a post's GMT datetime to a timestamp, using the local one when the GMT column holds the zero date.
+	 *
+	 * WordPress leaves the GMT columns at the zero date for some posts (drafts, imports) and treats the local
+	 * column as the truth. The HPOS migrator applies the same rule, so both stores read the same date.
+	 *
+	 * @since 11.3.0
+	 *
+	 * @param string $gmt_date   Datetime in GMT, possibly the zero date.
+	 * @param string $local_date The same datetime in the site timezone.
+	 * @return int|null
+	 */
+	protected function post_date_to_timestamp( $gmt_date, $local_date ) {
+		$timestamp = $this->string_to_timestamp( $gmt_date );
+		if ( null === $timestamp && '' !== $local_date && '0000-00-00 00:00:00' !== $local_date ) {
+			$timestamp = $this->string_to_timestamp( get_gmt_from_date( $local_date ) );
+		}
+		return $timestamp;
+	}
+
 	/**
 	 * Read order data. Can be overridden by child classes to load other props.
 	 *
@@ -981,8 +1001,6 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 		add_filter( 'wp_insert_post_data', array( $this, 'update_post_modified_data' ), 10, 2 );
 		$post_data = array(
 			'ID'                 => $order->get_id(),
-			'post_date'          => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
-			'post_date_gmt'      => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
 			'post_status'        => $this->get_post_status( $order ),
 			'post_parent'        => $order->get_parent_id(),
 			'edit_date'          => true,
@@ -991,7 +1009,13 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
 			'order_modified'     => ! is_null( $order->get_date_modified() ) ? gmdate( 'Y-m-d H:i:s', $order->get_date_modified( 'edit' )->getOffsetTimestamp() ) : '',
 			'order_modified_gmt' => ! is_null( $order->get_date_modified() ) ? gmdate( 'Y-m-d H:i:s', $order->get_date_modified( 'edit' )->getTimestamp() ) : '',
 		);
-		$updated   = wp_update_post( $post_data );
+		// An order with no created date leaves the post's date columns to WordPress, which keeps them unless they are zero too.
+		$date_created = $order->get_date_created( 'edit' );
+		if ( ! is_null( $date_created ) ) {
+			$post_data['post_date']     = gmdate( 'Y-m-d H:i:s', $date_created->getOffsetTimestamp() );
+			$post_data['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $date_created->getTimestamp() );
+		}
+		$updated = wp_update_post( $post_data );
 		remove_filter( 'wp_insert_post_data', array( $this, 'update_post_modified_data' ) );
 		return $updated;
 	}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index e1c84d16af1..43b066bd32a 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4010,3 +4010,164 @@ function wc_update_11203_normalize_stock_notification_emails() {

 	return false;
 }
+
+/**
+ * Give HPOS orders migrated without a created or updated date the dates their posts still hold.
+ *
+ * Earlier migrations copied a zero post_date_gmt verbatim, so the HPOS row ended up with no created date and the next
+ * save stamped it with the current time. Only rows whose date is NULL or the zero date are touched, and only when the
+ * order's post has a usable date. Placeholder posts count too: legacy cleanup keeps the date columns when it converts a
+ * post, and placeholders created for new HPOS orders carry the order's own date. Repaired orders are dropped from the
+ * order caches and queued for the Analytics import, which skipped them while they had no date. Batched, returns true
+ * while rows remain.
+ *
+ * @return bool True to run again.
+ */
+function wc_update_1130_repair_hpos_order_dates_from_posts() {
+	global $wpdb;
+
+	$orders_table = \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore::get_orders_table_name();
+	if ( $orders_table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $orders_table ) ) ) {
+		return false;
+	}
+
+	$last_id_option = 'woocommerce_update_1130_last_repaired_order_id';
+	$batch_size     = 500;
+	$zero           = '0000-00-00 00:00:00';
+	$type_list      = array();
+	$post_types     = array_merge( wc_get_order_types( 'cot-migration' ), array( \Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE ) );
+	foreach ( $post_types as $post_type ) {
+		$escaped = esc_sql( $post_type );
+		if ( is_string( $escaped ) ) {
+			$type_list[] = "'" . $escaped . "'";
+		}
+	}
+	$type_list = implode( ',', $type_list );
+
+	// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and the escaped type list cannot be prepared.
+	$rows = $wpdb->get_results(
+		$wpdb->prepare(
+			"SELECT o.id, o.date_created_gmt, o.date_updated_gmt, p.post_date, p.post_date_gmt, p.post_modified, p.post_modified_gmt
+			FROM {$orders_table} o
+			INNER JOIN {$wpdb->posts} p ON p.ID = o.id AND p.post_type IN ({$type_list})
+			WHERE o.id > %d
+			AND ( o.date_created_gmt IS NULL OR o.date_created_gmt = %s OR o.date_updated_gmt IS NULL OR o.date_updated_gmt = %s )
+			ORDER BY o.id ASC
+			LIMIT %d",
+			(int) get_option( $last_id_option, 0 ),
+			$zero,
+			$zero,
+			$batch_size
+		)
+	);
+	// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+	if ( '' !== $wpdb->last_error ) {
+		wc_get_logger()->error( sprintf( 'Stopped repairing HPOS order dates: %s', $wpdb->last_error ), array( 'source' => 'wc-updater' ) );
+		delete_option( $last_id_option );
+		return false;
+	}
+
+	// The rule WordPress applies to its own posts: the GMT column, or the local one when the GMT one is the zero date.
+	$gmt_from_post = function ( $gmt_date, $local_date ) use ( $zero ) {
+		if ( $gmt_date && $zero !== $gmt_date ) {
+			return $gmt_date;
+		}
+		if ( ! $local_date || $zero === $local_date ) {
+			return null;
+		}
+		$datetime = date_create( $local_date, wp_timezone() );
+		return $datetime ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ) : null;
+	};
+
+	// Repaired orders leave the caches (a cached object still has no date and would stamp the current time on its next save)
+	// and get queued for the Analytics import, which skipped them while they had no date.
+	$forget_and_import = function ( array $order_ids ) {
+		if ( ! $order_ids ) {
+			return;
+		}
+		wc_get_container()->get( \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore::class )->clear_cached_data( $order_ids );
+		$order_cache = wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class );
+		// With Analytics disabled nothing handles the import action, and queueing it would only leave failed actions behind.
+		$import_hook    = \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::get_action( 'import' );
+		$import_handled = is_string( $import_hook ) && has_action( $import_hook );
+		/**
+		 * Filters whether Analytics runs its imports inline instead of queueing them.
+		 *
+		 * @since 4.0.0
+		 * @param bool $disable Whether Action Scheduler is bypassed.
+		 */
+		$import_inline = ! get_option( 'schema-ActionScheduler_StoreSchema' ) || apply_filters( 'woocommerce_analytics_disable_action_scheduling', false );
+		foreach ( $order_ids as $order_id ) {
+			$order_cache->remove( $order_id );
+			if ( ! $import_handled ) {
+				continue;
+			}
+			if ( $import_inline ) {
+				\Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::import( $order_id );
+				continue;
+			}
+			// Queued directly: OrdersScheduler::schedule_action() first searches every pending action for a duplicate, which gets
+			// slower with each order queued here. A duplicate import only rewrites the same stats row.
+			WC()->queue()->schedule_single( time() + 5, $import_hook, array( $order_id ), (string) \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::$group );
+		}
+	};
+
+	$repaired_ids = array();
+	foreach ( $rows as $row ) {
+		$columns = array();
+		if ( ! $row->date_created_gmt || $zero === $row->date_created_gmt ) {
+			$columns['date_created_gmt'] = $gmt_from_post( $row->post_date_gmt, $row->post_date );
+		}
+		if ( ! $row->date_updated_gmt || $zero === $row->date_updated_gmt ) {
+			$columns['date_updated_gmt'] = $gmt_from_post( $row->post_modified_gmt, $row->post_modified );
+		}
+		$columns = array_filter( $columns );
+		if ( empty( $columns ) ) {
+			continue;
+		}
+		// Each column is written only while it is still empty, so a save that lands between the read and the write wins.
+		$assignments = array();
+		$values      = array();
+		foreach ( $columns as $column => $value ) {
+			$assignments[] = "{$column} = IF( {$column} IS NULL OR {$column} = %s, %s, {$column} )";
+			$values[]      = $zero;
+			$values[]      = $value;
+		}
+		$values[] = (int) $row->id;
+		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table and column names are code-defined, values go through prepare().
+		$updated = $wpdb->query( $wpdb->prepare( "UPDATE {$orders_table} SET " . implode( ', ', $assignments ) . ' WHERE id = %d', $values ) );
+		if ( false === $updated ) {
+			wc_get_logger()->error( sprintf( 'Stopped repairing HPOS order dates at order #%d: %s', (int) $row->id, $wpdb->last_error ), array( 'source' => 'wc-updater' ) );
+			$forget_and_import( $repaired_ids );
+			delete_option( $last_id_option );
+			return false;
+		}
+		// Zero rows means a save or another run filled the dates first, and that writer already took care of the rest.
+		if ( $updated > 0 ) {
+			$repaired_ids[] = (int) $row->id;
+		}
+	}
+
+	$forget_and_import( $repaired_ids );
+
+	if ( count( $rows ) === $batch_size ) {
+		// The cursor only ever moves forward: a concurrent run (the queue plus `wp wc update`) may already have saved this id or a
+		// later one, and update_option() reports that as false just like a failed write. Without a saved cursor the next run would
+		// pick the same rows again, and rows whose post has no date never leave the selection.
+		$cursor = (int) end( $rows )->id;
+		if ( (int) get_option( $last_id_option, 0 ) < $cursor && ! update_option( $last_id_option, $cursor, false ) ) {
+			wp_cache_delete( $last_id_option, 'options' );
+			if ( (int) get_option( $last_id_option, 0 ) < $cursor ) {
+				wc_get_logger()->error( 'Stopped repairing HPOS order dates: the progress cursor could not be saved.', array( 'source' => 'wc-updater' ) );
+				delete_option( $last_id_option );
+				return false;
+			}
+		}
+		return true;
+	}
+
+	delete_option( $last_id_option );
+
+	return false;
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 896874daa16..0c2f7717aab 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -16570,13 +16570,13 @@ parameters:
 		-
 			message: '#^Cannot call method getOffsetTimestamp\(\) on WC_DateTime\|null\.$#'
 			identifier: method.nonObject
-			count: 5
+			count: 4
 			path: includes/data-stores/abstract-wc-order-data-store-cpt.php

 		-
 			message: '#^Cannot call method getTimestamp\(\) on WC_DateTime\|null\.$#'
 			identifier: method.nonObject
-			count: 5
+			count: 4
 			path: includes/data-stores/abstract-wc-order-data-store-cpt.php

 		-
@@ -60100,18 +60100,6 @@ parameters:
 			count: 1
 			path: src/Internal/DataStores/Orders/OrdersTableDataStore.php

-		-
-			message: '#^Cannot call method getOffsetTimestamp\(\) on WC_DateTime\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
-
-		-
-			message: '#^Cannot call method getTimestamp\(\) on WC_DateTime\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
-
 		-
 			message: '#^Cannot call method get_download_permissions_granted\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
 			identifier: method.nonObject
diff --git a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
index f524ce438f5..8af67d980f2 100644
--- a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
+++ b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
@@ -69,13 +69,18 @@ class PostToOrderTableMigrator extends MetaToCustomTableMigrator {
 				'type'        => 'string',
 				'destination' => 'status',
 			),
+			// WordPress leaves the GMT columns at the zero date for some posts (drafts, imports) and treats the local
+			// columns as the truth in that case. Without the fallback such orders arrive here with no date at all, and the
+			// next save stamps them with the current time.
 			'post_date_gmt'     => array(
-				'type'        => 'date',
-				'destination' => 'date_created_gmt',
+				'type'            => 'date',
+				'destination'     => 'date_created_gmt',
+				'fallback_column' => 'post_date',
 			),
 			'post_modified_gmt' => array(
-				'type'        => 'date',
-				'destination' => 'date_updated_gmt',
+				'type'            => 'date',
+				'destination'     => 'date_updated_gmt',
+				'fallback_column' => 'post_modified',
 			),
 			'post_parent'       => array(
 				'type'        => 'int',
diff --git a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
index 597703121cc..2f0f82678f0 100644
--- a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
+++ b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
@@ -13,6 +13,11 @@ namespace Automattic\WooCommerce\Database\Migrations;
  */
 abstract class MetaToCustomTableMigrator extends TableMigrator {

+	/**
+	 * The value MySQL stores for a DATETIME that was never set.
+	 */
+	private const ZERO_DATE = '0000-00-00 00:00:00';
+
 	/**
 	 * Config for tables being migrated and migrated from. See __construct() for detailed config.
 	 *
@@ -79,6 +84,7 @@ abstract class MetaToCustomTableMigrator extends TableMigrator {
 	 *  '$source_column_name_1' => array( // $source_column_name_1 is column name in source table, or a select statement.
 	 *      'type' => 'type of value, could be string/int/date/float.',
 	 *      'destination' => 'name of the column in column name where this data should be inserted in.',
+	 *      'fallback_column' => 'optional, for the date type only: source column holding the same datetime in the site timezone, used when the source column is empty or the zero date.',
 	 *  ),
 	 *  '$source_column_name_2' => array(
 	 *          ......
@@ -444,8 +450,11 @@ WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder ) $additio
 			} else {
 				$entity_keys[] = "$source_entity_table.$column_name";
 			}
+			if ( isset( $column_schema['fallback_column'] ) ) {
+				$entity_keys[] = "$source_entity_table.{$column_schema['fallback_column']}";
+			}
 		}
-		$entity_column_string = implode( ', ', $entity_keys );
+		$entity_column_string = implode( ', ', array_unique( $entity_keys ) );
 		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_rel_id_column, $source_destination_rel_id_column etc is escaped for backticks. $where clause and $order_by should already be escaped.
 		$query = $wpdb->prepare(
 			"
@@ -536,7 +545,11 @@ WHERE
 			foreach ( $this->core_column_mapping as $column_name => $schema ) {
 				$custom_table_column_name = $schema['destination'] ?? $column_name;
 				$value                    = $entity->$column_name;
-				$value                    = $this->validate_data( $value, $schema['type'] );
+				if ( isset( $schema['fallback_column'] ) && ( null === $value || '' === $value || self::ZERO_DATE === $value ) ) {
+					// With nothing to fall back to, the original value is stored as it always was.
+					$value = $this->local_date_to_gmt( $entity->{$schema['fallback_column']} ?? null ) ?? $value;
+				}
+				$value = $this->validate_data( $value, $schema['type'] );
 				if ( is_wp_error( $value ) ) {
 					$error_records[ $entity->primary_key_id ][ $custom_table_column_name ] = $value->get_error_code();
 				} else {
@@ -655,6 +668,9 @@ WHERE
 			$source_select_column         = isset( $schema['select_clause'] ) ? $schema['select_clause'] : "$source_table.$column_name";
 			$source_select_clauses[]      = "$source_select_column as {$source_table}_{$column_name}";
 			$destination_select_clauses[] = "$destination_table.{$schema['destination']} as {$destination_table}_{$schema['destination']}";
+			if ( isset( $schema['fallback_column'] ) ) {
+				$source_select_clauses[] = "$source_table.{$schema['fallback_column']} as {$source_table}_{$schema['fallback_column']}";
+			}
 		}

 		foreach ( $this->meta_column_mapping as $meta_key => $schema ) {
@@ -866,13 +882,44 @@ WHERE $where_clause
 			} else {
 				$row[ $alias ] = ( new \DateTime( "@{$row[ $alias ]}" ) )->format( 'Y-m-d H:i:s' );
 			}
-			if ( '0000-00-00 00:00:00' === $row[ $destination_alias ] ) {
+			if ( self::ZERO_DATE === $row[ $destination_alias ] ) {
+				$row[ $destination_alias ] = null;
+			}
+		}
+		if ( 'date' === $schema['type'] ) {
+			if ( '' === $row[ $alias ] || self::ZERO_DATE === $row[ $alias ] ) {
+				$row[ $alias ] = null;
+			}
+			if ( null === $row[ $alias ] && isset( $schema['fallback_column'] ) ) {
+				$fallback_alias = "{$this->schema_config['source']['entity']['table_name']}_{$schema['fallback_column']}";
+				$row[ $alias ]  = $this->local_date_to_gmt( $row[ $fallback_alias ] ?? null );
+			}
+			if ( self::ZERO_DATE === $row[ $destination_alias ] ) {
 				$row[ $destination_alias ] = null;
 			}
 		}
 		return $row;
 	}

+	/**
+	 * Convert a datetime string expressed in the site's timezone to its GMT equivalent.
+	 *
+	 * Unlike get_gmt_from_date(), an empty, zero or unparsable value yields null rather than the Unix epoch.
+	 *
+	 * @param string|null $value Datetime string in the site's timezone.
+	 * @return string|null GMT datetime string, or null when there is no usable value.
+	 */
+	private function local_date_to_gmt( ?string $value ): ?string {
+		if ( null === $value || '' === $value || self::ZERO_DATE === $value ) {
+			return null;
+		}
+		$datetime = date_create( $value, wp_timezone() );
+		if ( false === $datetime ) {
+			return null;
+		}
+		return $datetime->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+	}
+
 	/**
 	 * Helper method to get default value of a type.
 	 *
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index ace31dd00ef..5d481d63b96 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -2347,13 +2347,18 @@ FROM $order_meta_table
 		$data_sync = wc_get_container()->get( DataSynchronizer::class );

 		$data = array(
-			'post_type'     => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
-			'post_status'   => 'draft',
-			'post_parent'   => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
-			'post_date'     => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
-			'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
+			'post_type'   => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
+			'post_status' => 'draft',
+			'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
 		);

+		// An order with no created date leaves the post dates to WordPress rather than failing the whole sync batch.
+		$date_created = $order->get_date_created( 'edit' );
+		if ( ! is_null( $date_created ) ) {
+			$data['post_date']     = gmdate( 'Y-m-d H:i:s', $date_created->getOffsetTimestamp() );
+			$data['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $date_created->getTimestamp() );
+		}
+
 		if ( 'backfill' === $context ) {
 			if ( ! $order->get_id() ) {
 				return 0;
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index 3ac20437fea..f4fbe8c3b5e 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -57,6 +57,52 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
 		parent::tearDown();
 	}

+	/**
+	 * @testdox Should read the created and modified dates from the local post columns when the GMT ones hold the zero date.
+	 */
+	public function test_read_falls_back_to_local_dates_when_gmt_dates_are_zero(): void {
+		global $wpdb;
+
+		update_option( 'timezone_string', 'Europe/Amsterdam' );
+		$order = OrderHelper::create_order();
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date_gmt'     => '0000-00-00 00:00:00',
+				'post_modified_gmt' => '0000-00-00 00:00:00',
+			),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+		$post = get_post( $order->get_id() );
+
+		$read = new WC_Order();
+		$read->set_id( $order->get_id() );
+		( new WC_Order_Data_Store_CPT() )->read( $read );
+
+		$this->assertSame( get_gmt_from_date( $post->post_date ), $read->get_date_created()->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ), 'The created date should come from post_date.' );
+		$this->assertSame( get_gmt_from_date( $post->post_modified ), $read->get_date_modified()->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ), 'The modified date should come from post_modified.' );
+	}
+
+	/**
+	 * @testdox Should leave the post dates untouched when backfilling an order that has no created date.
+	 */
+	public function test_update_order_from_object_keeps_post_dates_for_order_without_created_date(): void {
+		$order    = OrderHelper::create_order();
+		$before   = get_post( $order->get_id() );
+		$dateless = new WC_Order();
+		$dateless->set_id( $order->get_id() );
+		$dateless->set_status( OrderStatus::COMPLETED );
+		$dateless->set_date_created( null );
+
+		$this->assertNotFalse( ( new WC_Order_Data_Store_CPT() )->update_order_from_object( $dateless ) );
+
+		$after = get_post( $order->get_id() );
+		$this->assertSame( $before->post_date, $after->post_date, 'post_date should be kept.' );
+		$this->assertSame( $before->post_date_gmt, $after->post_date_gmt, 'post_date_gmt should be kept.' );
+		$this->assertSame( 'wc-completed', $after->post_status, 'Other fields should still be written.' );
+	}
+
 	/**
 	 * Test that refund cache are invalidated correctly when refund is deleted.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index b9e25ef4e5c..d83e01e5576 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -5,6 +5,12 @@
  * @package WooCommerce\Tests\Functions.
  */

+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\Caches\OrderCache;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
 use Automattic\Jetpack\Constants;
 use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
 use Automattic\WooCommerce\Admin\Notes\Note;
@@ -21,10 +27,30 @@ use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGallery
  */
 class WC_Update_Functions_Test extends \WC_Unit_Test_Case {

+	/**
+	 * Whether HPOS was authoritative before the test.
+	 *
+	 * @var bool
+	 */
+	private $previous_hpos_state;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		// Tests that migrate orders leave the two storages out of sync, which would otherwise block restoring the storage setting.
+		add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+		$this->previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+		OrderHelper::create_order_custom_table_if_not_exist();
+	}
+
 	/**
 	 * Tear down test fixtures.
 	 */
 	public function tearDown(): void {
+		OrderHelper::toggle_cot_feature_and_usage( $this->previous_hpos_state );
+		remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
 		Constants::clear_single_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' );
 		delete_option( 'woocommerce_feature_customer_stock_notifications_enabled' );
 		parent::tearDown();
@@ -726,6 +752,169 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		return $variation_id;
 	}

+	/**
+	 * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should fill HPOS dates that an earlier migration left empty from the order's post.
+	 */
+	public function test_wc_update_1130_repairs_hpos_dates_from_posts(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		update_option( 'timezone_string', 'Europe/Amsterdam' );
+		$order_id = OrderHelper::create_complex_wp_post_order();
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date_gmt'     => '0000-00-00 00:00:00',
+				'post_modified_gmt' => '0000-00-00 00:00:00',
+			),
+			array( 'ID' => $order_id )
+		);
+		clean_post_cache( $order_id );
+		wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $order_id ) );
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		// The shape earlier migrations left behind.
+		$wpdb->update(
+			$orders_table,
+			array(
+				'date_created_gmt' => '0000-00-00 00:00:00',
+				'date_updated_gmt' => null,
+			),
+			array( 'id' => $order_id )
+		);
+		$post = get_post( $order_id );
+
+		// A second order whose legacy data was cleaned up: the post is a placeholder but keeps its date columns.
+		$cleaned_id = OrderHelper::create_complex_wp_post_order();
+		$wpdb->update( $wpdb->posts, array( 'post_date_gmt' => '0000-00-00 00:00:00' ), array( 'ID' => $cleaned_id ) );
+		clean_post_cache( $cleaned_id );
+		wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $cleaned_id ) );
+		$wpdb->update( $orders_table, array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $cleaned_id ) );
+		$wpdb->update( $wpdb->posts, array( 'post_type' => DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE ), array( 'ID' => $cleaned_id ) );
+		clean_post_cache( $cleaned_id );
+		$cleaned_post = get_post( $cleaned_id );
+
+		// A cached order object without a date must not survive the repair.
+		$order_cache = wc_get_container()->get( OrderCache::class );
+		$order_cache->set( wc_get_order( $order_id ), $order_id );
+		// Creating the orders queued their own imports: clear them so the assertion below is about the routine.
+		as_unschedule_all_actions( 'wc-admin_import_orders' );
+
+		$this->assertFalse( wc_update_1130_repair_hpos_order_dates_from_posts(), 'A single small batch should not request another run' );
+
+		$row = $wpdb->get_row( $wpdb->prepare( "SELECT date_created_gmt, date_updated_gmt FROM {$orders_table} WHERE id = %d", $order_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertSame( get_gmt_from_date( $post->post_date ), $row->date_created_gmt, 'Created date should come from post_date converted with the site timezone' );
+		$this->assertSame( get_gmt_from_date( $post->post_modified ), $row->date_updated_gmt, 'Updated date should come from post_modified' );
+		$this->assertNotSame( $post->post_date, $row->date_created_gmt, 'The local date must have been converted' );
+		$this->assertSame( get_gmt_from_date( $cleaned_post->post_date ), $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $cleaned_id ) ), 'A cleaned-up order is repaired from its placeholder post' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertFalse( $order_cache->is_cached( $order_id ), 'The cached order object must be dropped' );
+		$this->assertNotFalse( as_next_scheduled_action( 'wc-admin_import_orders', array( $order_id ) ), 'The Analytics import must be queued for the repaired order' );
+	}
+
+	/**
+	 * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should import repaired orders inline when Analytics scheduling is disabled.
+	 */
+	public function test_wc_update_1130_imports_inline_when_analytics_scheduling_is_disabled(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$order_id = OrderHelper::create_complex_wp_post_order();
+		wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $order_id ) );
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		$wpdb->update( $orders_table, array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $order_id ) );
+		OrderHelper::toggle_cot_feature_and_usage( true );
+		// Creating the order queued its own import. Start from a clean slate so only the routine's behaviour is measured.
+		as_unschedule_all_actions( 'wc-admin_import_orders' );
+		$wpdb->delete( $wpdb->prefix . 'wc_order_stats', array( 'order_id' => $order_id ) );
+		add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+
+		wc_update_1130_repair_hpos_order_dates_from_posts();
+
+		$this->assertFalse( as_next_scheduled_action( 'wc-admin_import_orders', array( $order_id ) ), 'Nothing should be queued when scheduling is disabled' );
+		$this->assertNotNull( $wpdb->get_var( $wpdb->prepare( "SELECT order_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) ), 'The order should have been imported inline' );
+	}
+
+	/**
+	 * Insert a full batch of HPOS rows with no created date whose posts have no date either, so the repair has to advance its cursor.
+	 *
+	 * @return int The id of the last row in the batch.
+	 */
+	private function insert_full_batch_of_unrepairable_orders(): int {
+		global $wpdb;
+
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		$first_id     = (int) $wpdb->get_var( "SELECT GREATEST( COALESCE( ( SELECT MAX( ID ) FROM {$wpdb->posts} ), 0 ), COALESCE( ( SELECT MAX( id ) FROM {$orders_table} ), 0 ) )" ) + 1; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$posts        = array();
+		$orders       = array();
+		for ( $id = $first_id; $id < $first_id + 500; $id++ ) {
+			$posts[]  = "( {$id}, 'shop_order', 'wc-completed', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '', '', '', '', '', '' )";
+			$orders[] = "( {$id}, 'shop_order', 'wc-completed', NULL, '2026-01-01 00:00:00' )";
+		}
+		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Generated integers and literals only.
+		$wpdb->query( "INSERT INTO {$wpdb->posts} ( ID, post_type, post_status, post_date, post_date_gmt, post_modified, post_modified_gmt, post_content, post_title, post_excerpt, to_ping, pinged, post_content_filtered ) VALUES " . implode( ',', $posts ) );
+		$wpdb->query( "INSERT INTO {$orders_table} ( id, type, status, date_created_gmt, date_updated_gmt ) VALUES " . implode( ',', $orders ) );
+		// phpcs:enable
+
+		return $first_id + 499;
+	}
+
+	/**
+	 * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should keep going when another run has already saved the same or a later cursor.
+	 *
+	 * @testWith [0]
+	 *           [1000]
+	 *
+	 * @param int $ahead How far past this batch the other run has already moved the cursor.
+	 */
+	public function test_wc_update_1130_accepts_a_cursor_saved_by_a_concurrent_run( int $ahead ): void {
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$last_id      = $this->insert_full_batch_of_unrepairable_orders();
+		$option       = 'woocommerce_update_1130_last_repaired_order_id';
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		// The other run saves its cursor right after this one has read its batch.
+		$concurrent_save = function ( $query ) use ( $option, $last_id, $ahead, $orders_table ) {
+			if ( false !== strpos( $query, "FROM {$orders_table} o" ) ) {
+				update_option( $option, $last_id + $ahead, false );
+			}
+			return $query;
+		};
+		add_filter( 'query', $concurrent_save );
+
+		$this->assertTrue( wc_update_1130_repair_hpos_order_dates_from_posts(), 'A cursor saved by another run is progress, not a failure' );
+		$this->assertSame( $last_id + $ahead, (int) get_option( $option ), 'The cursor must never move backwards' );
+	}
+
+	/**
+	 * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should leave rows alone when they have dates or their post has none.
+	 */
+	public function test_wc_update_1130_leaves_dated_rows_and_dateless_posts_alone(): void {
+		global $wpdb;
+
+		include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+		$dated_id    = OrderHelper::create_complex_wp_post_order();
+		$dateless_id = OrderHelper::create_complex_wp_post_order();
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'     => '0000-00-00 00:00:00',
+				'post_date_gmt' => '0000-00-00 00:00:00',
+			),
+			array( 'ID' => $dateless_id )
+		);
+		clean_post_cache( $dateless_id );
+		wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $dated_id, $dateless_id ) );
+		$orders_table = OrdersTableDataStore::get_orders_table_name();
+		$dated_before = $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dated_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+		$this->assertFalse( wc_update_1130_repair_hpos_order_dates_from_posts() );
+
+		$this->assertSame( $dated_before, $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dated_id ) ), 'A dated row must not change' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		$this->assertSame( '0000-00-00 00:00:00', $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dateless_id ) ), 'A post with no date gives nothing to repair from' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+	}
+
 	/**
 	 * @testdox Migration rewrites stored stock notification emails in canonical form and leaves canonical rows alone.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php b/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
index bbfc038375d..35c54b87dcf 100644
--- a/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Database\Migrations\CustomOrderTable;

 use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
+use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostToOrderTableMigrator;
 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
@@ -124,6 +125,72 @@ class PostsToOrdersMigrationControllerTest extends \WC_Unit_Test_Case {
 		$this->assert_metadata_is_migrated( $order );
 	}

+	/**
+	 * @testdox Should derive the GMT created and modified dates from the local ones when the post holds the zero date in the GMT columns.
+	 */
+	public function test_migration_for_order_with_zero_gmt_dates(): void {
+		global $wpdb;
+
+		update_option( 'timezone_string', 'Europe/Amsterdam' );
+		$order = wc_get_order( OrderHelper::create_complex_wp_post_order() );
+		$this->clear_all_orders();
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date_gmt'     => '0000-00-00 00:00:00',
+				'post_modified_gmt' => '0000-00-00 00:00:00',
+			),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+		$post = get_post( $order->get_id() );
+
+		$this->sut->migrate_order( $order->get_id() );
+
+		$db_order = $this->get_order_from_cot( $order );
+		$this->assertEquals( get_gmt_from_date( $post->post_date ), $db_order->date_created_gmt, 'The created date should be derived from post_date.' );
+		$this->assertEquals( get_gmt_from_date( $post->post_modified ), $db_order->date_updated_gmt, 'The modified date should be derived from post_modified.' );
+		$this->assertNotEquals( $post->post_date, $db_order->date_created_gmt, 'The local date should have been converted to GMT.' );
+
+		$migrator = new PostToOrderTableMigrator();
+		$this->assertEmpty( $migrator->verify_migrated_data( array( $order->get_id() ) ), 'Verification should accept the derived dates.' );
+
+		// A row migrated before the fallback existed still holds the zero date: verification must report it.
+		$wpdb->update( $this->data_store::get_orders_table_name(), array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $order->get_id() ) );
+		$failures = $migrator->verify_migrated_data( array( $order->get_id() ) );
+		$this->assertSame( 'post_date_gmt', $failures[ $order->get_id() ][0]['column'] ?? null, 'Verification should flag a created date that was not derived.' );
+	}
+
+	/**
+	 * @testdox Should keep the zero date when both the GMT and the local columns hold the zero date.
+	 */
+	public function test_migration_for_order_with_zero_gmt_and_local_dates(): void {
+		global $wpdb;
+
+		$order = wc_get_order( OrderHelper::create_complex_wp_post_order() );
+		$this->clear_all_orders();
+		$wpdb->update(
+			$wpdb->posts,
+			array(
+				'post_date'         => '0000-00-00 00:00:00',
+				'post_date_gmt'     => '0000-00-00 00:00:00',
+				'post_modified'     => '0000-00-00 00:00:00',
+				'post_modified_gmt' => '0000-00-00 00:00:00',
+			),
+			array( 'ID' => $order->get_id() )
+		);
+		clean_post_cache( $order->get_id() );
+
+		$this->sut->migrate_order( $order->get_id() );
+
+		$db_order = $this->get_order_from_cot( $order );
+		$this->assertSame( '0000-00-00 00:00:00', $db_order->date_created_gmt, 'With no date to derive the created date from, the zero date is stored as before.' );
+		$this->assertSame( '0000-00-00 00:00:00', $db_order->date_updated_gmt, 'With no date to derive the modified date from, the zero date is stored as before.' );
+
+		$migrator = new PostToOrderTableMigrator();
+		$this->assertEmpty( $migrator->verify_migrated_data( array( $order->get_id() ) ), 'Verification should accept the empty dates.' );
+	}
+
 	/**
 	 * Test that already migrated order isn't migrated twice.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index ad4decc7ad4..877c187b13f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1478,6 +1478,30 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		}
 	}

+	/**
+	 * @testDox Backfilling an order that has no created date and no post row should create the post instead of failing.
+	 */
+	public function test_backfill_post_record_for_order_without_created_date_or_post() {
+		global $wpdb;
+		$this->toggle_cot_feature_and_usage( true );
+		$this->disable_cot_sync();
+		$order = $this->create_complex_cot_order();
+		$wpdb->delete( $wpdb->posts, array( 'ID' => $order->get_id() ) );
+		$wpdb->update( $this->sut::get_orders_table_name(), array( 'date_created_gmt' => null ), array( 'id' => $order->get_id() ) );
+		clean_post_cache( $order->get_id() );
+		$this->sut->clear_cached_data( array( $order->get_id() ) );
+
+		$dateless = new WC_Order();
+		$dateless->set_id( $order->get_id() );
+		$this->switch_data_store( $dateless, $this->sut );
+		$this->sut->read( $dateless );
+		$this->assertNull( $dateless->get_date_created(), 'Precondition: the order has no created date.' );
+
+		$this->sut->backfill_post_record( $dateless );
+
+		$this->assertInstanceOf( \WP_Post::class, get_post( $order->get_id() ), 'The backup post should exist after the backfill.' );
+	}
+
 	/**
 	 * @testDox Test `get_unpaid_orders()`.
 	 */
@@ -1558,6 +1582,29 @@ class OrdersTableDataStoreTests extends \HposTestCase {
 		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
 	}

+	/**
+	 * @testDox Sync on read should keep the created date when the post's GMT date column holds the zero date.
+	 */
+	public function test_sync_on_read_keeps_created_date_when_post_gmt_date_is_zero() {
+		global $wpdb;
+		$this->toggle_cot_feature_and_usage( true );
+		$this->enable_cot_sync();
+		add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+		$order   = $this->create_complex_cot_order();
+		$created = $order->get_date_created()->getTimestamp();
+		$wpdb->update( $wpdb->posts, array( 'post_date_gmt' => '0000-00-00 00:00:00' ), array( 'ID' => $order->get_id() ) );
+		clean_post_cache( $order->get_id() );
+
+		$refreshed_order = new WC_Order();
+		$refreshed_order->set_id( $order->get_id() );
+		$this->switch_data_store( $refreshed_order, $this->sut );
+		$this->sut->read( $refreshed_order );
+
+		$this->assertSame( $created, $refreshed_order->get_date_created()->getTimestamp(), 'The posts reader must agree with HPOS, so nothing is copied back.' );
+		$this->assertSame( gmdate( 'Y-m-d H:i:s', $created ), $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$this->sut::get_orders_table_name()} WHERE id = %d", $order->get_id() ) ), 'The HPOS row must keep its date.' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+		remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
+	}
+
 	/**
 	 * @testDox When there are direct writes to posts data, order should synced upon reading.
 	 */