Commit 10a3de13534 for woocommerce

commit 10a3de135347e4a676a5909004f7c5f37757c5f2
Author: Brandon Kraft <public@brandonkraft.com>
Date:   Wed Mar 25 15:50:28 2026 +0100

    Fix scheduled import preference lost after option rename (#63808)

    * Fix scheduled import preference lost after option rename

    The `woocommerce_analytics_immediate_import` option was renamed to
    `woocommerce_analytics_scheduled_import` in 10.5.0 (185d9c177c23)
    with inverted semantics but no migration. Stores that had opted into
    scheduled imports silently reverted to immediate imports on upgrade.

    Add a runtime fallback in `is_scheduled_import_enabled()` to check the
    legacy option (with correct semantic inversion) when the new option is
    absent. Add a migration in the 10.7.0 upgrade routine that converts the
    legacy value and cleans it up, guarding against write failures by only
    deleting the legacy option after a successful `add_option()`.

    Fixes WOO6-53

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-scheduled-import-migration-WOO6-53 b/plugins/woocommerce/changelog/fix-scheduled-import-migration-WOO6-53
new file mode 100644
index 00000000000..ad70009387e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-scheduled-import-migration-WOO6-53
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix scheduled import preference lost after analytics option rename in 10.5.0.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index a1c0f3bb66c..4f13b39780a 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -325,6 +325,9 @@ class WC_Install {
 		'10.7.0' => array(
 			'wc_update_1070_disable_hpos_sync_on_read',
 		),
+		'10.8.0' => array(
+			'wc_update_1080_migrate_analytics_import_option',
+		),
 	);

 	/**
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index a307e641b2d..d2178f97928 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -3418,3 +3418,44 @@ function wc_update_1070_disable_hpos_sync_on_read(): void {

 	WC_Admin_Notices::add_notice( 'hpos_sync_on_read_disabled' );
 }
+
+/**
+ * Migrate the legacy analytics immediate import option to the renamed scheduled import option.
+ *
+ * In 10.5.0, `woocommerce_analytics_immediate_import` was renamed to
+ * `woocommerce_analytics_scheduled_import` with inverted semantics, but no
+ * migration was added. Stores that had opted into scheduled imports (legacy
+ * value 'no') silently reverted to immediate imports on upgrade.
+ *
+ * @since 10.8.0
+ *
+ * @return void
+ */
+function wc_update_1080_migrate_analytics_import_option(): void {
+	$legacy_option = 'woocommerce_analytics_immediate_import';
+	$new_option    = 'woocommerce_analytics_scheduled_import';
+
+	$legacy_value = get_option( $legacy_option, false );
+
+	// Nothing to migrate if the legacy option was never set.
+	if ( false === $legacy_value ) {
+		return;
+	}
+
+	// If the new option already exists, just clean up the legacy option.
+	if ( false !== get_option( $new_option, false ) ) {
+		delete_option( $legacy_option );
+		return;
+	}
+
+	// Invert the semantics: legacy 'yes' (immediate) = new 'no' (not scheduled),
+	// legacy 'no' (not immediate) = new 'yes' (scheduled).
+	$new_value = 'no' === $legacy_value ? 'yes' : 'no';
+
+	// Only delete the legacy option if the new option was written successfully.
+	// On failure, the runtime fallback in is_scheduled_import_enabled() can
+	// still read the legacy option to preserve the store's preference.
+	if ( add_option( $new_option, $new_value ) ) {
+		delete_option( $legacy_option );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
index 577f57c7746..ef94a004b7c 100644
--- a/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
+++ b/plugins/woocommerce/src/Internal/Admin/Schedulers/OrdersScheduler.php
@@ -60,6 +60,16 @@ class OrdersScheduler extends ImportScheduler {
 	 */
 	const SCHEDULED_IMPORT_OPTION = 'woocommerce_analytics_scheduled_import';

+	/**
+	 * Legacy option name before the rename in 10.5.0.
+	 *
+	 * Used as a fallback during upgrades before the migration routine runs.
+	 * The old option stored inverted semantics: 'yes' = immediate, 'no' = scheduled.
+	 *
+	 * @var string
+	 */
+	const LEGACY_IMMEDIATE_IMPORT_OPTION = 'woocommerce_analytics_immediate_import';
+
 	/**
 	 * Default value for the scheduled import option.
 	 *
@@ -772,6 +782,21 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
 			return false;
 		}

-		return 'yes' === get_option( self::SCHEDULED_IMPORT_OPTION, self::SCHEDULED_IMPORT_OPTION_DEFAULT_VALUE );
+		$value = get_option( self::SCHEDULED_IMPORT_OPTION, false );
+
+		if ( false !== $value ) {
+			return 'yes' === $value;
+		}
+
+		// Fall back to the legacy option (pre-10.5.0) which used inverted semantics:
+		// 'yes' meant immediate import (= not scheduled), 'no' meant scheduled.
+		$legacy_value = get_option( self::LEGACY_IMMEDIATE_IMPORT_OPTION, false );
+
+		if ( false !== $legacy_value ) {
+			return 'no' === $legacy_value;
+		}
+
+		// Neither option exists — use the default (not scheduled).
+		return false;
 	}
 }
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 d671b488874..f082213d01f 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -272,4 +272,55 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
 		// Verify the actions were removed.
 		$this->assertFalse( as_has_scheduled_action( 'fetch_patterns' ), 'fetch_patterns action should be removed after update' );
 	}
+
+	/**
+	 * @testdox Migration converts legacy 'no' (not immediate) to new 'yes' (scheduled).
+	 */
+	public function test_migrate_analytics_import_option_legacy_no_becomes_yes(): void {
+		delete_option( 'woocommerce_analytics_scheduled_import' );
+		update_option( 'woocommerce_analytics_immediate_import', 'no' );
+
+		wc_update_1080_migrate_analytics_import_option();
+
+		$this->assertSame( 'yes', get_option( 'woocommerce_analytics_scheduled_import' ) );
+		$this->assertFalse( get_option( 'woocommerce_analytics_immediate_import' ) );
+	}
+
+	/**
+	 * @testdox Migration converts legacy 'yes' (immediate) to new 'no' (not scheduled).
+	 */
+	public function test_migrate_analytics_import_option_legacy_yes_becomes_no(): void {
+		delete_option( 'woocommerce_analytics_scheduled_import' );
+		update_option( 'woocommerce_analytics_immediate_import', 'yes' );
+
+		wc_update_1080_migrate_analytics_import_option();
+
+		$this->assertSame( 'no', get_option( 'woocommerce_analytics_scheduled_import' ) );
+		$this->assertFalse( get_option( 'woocommerce_analytics_immediate_import' ) );
+	}
+
+	/**
+	 * @testdox Migration does nothing when legacy option is absent.
+	 */
+	public function test_migrate_analytics_import_option_no_legacy_option(): void {
+		delete_option( 'woocommerce_analytics_immediate_import' );
+		delete_option( 'woocommerce_analytics_scheduled_import' );
+
+		wc_update_1080_migrate_analytics_import_option();
+
+		$this->assertFalse( get_option( 'woocommerce_analytics_scheduled_import' ) );
+	}
+
+	/**
+	 * @testdox Migration preserves existing new option and deletes legacy.
+	 */
+	public function test_migrate_analytics_import_option_new_option_already_exists(): void {
+		update_option( 'woocommerce_analytics_scheduled_import', 'yes' );
+		update_option( 'woocommerce_analytics_immediate_import', 'yes' );
+
+		wc_update_1080_migrate_analytics_import_option();
+
+		$this->assertSame( 'yes', get_option( 'woocommerce_analytics_scheduled_import' ) );
+		$this->assertFalse( get_option( 'woocommerce_analytics_immediate_import' ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
index f9191930378..8ccfc9c123c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Schedulers/OrdersSchedulerTest.php
@@ -35,6 +35,7 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_DATE_OPTION );
 		delete_option( OrdersScheduler::LAST_PROCESSED_ORDER_ID_OPTION );
 		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
+		delete_option( OrdersScheduler::LEGACY_IMMEDIATE_IMPORT_OPTION );

 		// Clean up any scheduled actions.
 		$this->clear_scheduled_batch_processor();
@@ -387,6 +388,61 @@ class OrdersSchedulerTest extends WC_Unit_Test_Case {
 		$this->assertSame( -1, $result );
 	}

+	/**
+	 * @testdox is_scheduled_import_enabled falls back to legacy option when new option is absent.
+	 */
+	public function test_is_scheduled_import_enabled_falls_back_to_legacy_option(): void {
+		// Simulate a pre-10.5.0 store that opted into scheduled imports (legacy 'no' = not immediate = scheduled).
+		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
+		update_option( OrdersScheduler::LEGACY_IMMEDIATE_IMPORT_OPTION, 'no' );
+
+		$reflection = new \ReflectionClass( OrdersScheduler::class );
+		$method     = $reflection->getMethod( 'is_scheduled_import_enabled' );
+		$method->setAccessible( true );
+
+		$this->assertTrue(
+			$method->invoke( null ),
+			'Legacy option "no" (not immediate) should be interpreted as scheduled import enabled'
+		);
+	}
+
+	/**
+	 * @testdox is_scheduled_import_enabled falls back to legacy option 'yes' correctly.
+	 */
+	public function test_is_scheduled_import_enabled_falls_back_to_legacy_option_yes(): void {
+		// Simulate a pre-10.5.0 store with default immediate import (legacy 'yes' = immediate = not scheduled).
+		delete_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION );
+		update_option( OrdersScheduler::LEGACY_IMMEDIATE_IMPORT_OPTION, 'yes' );
+
+		$reflection = new \ReflectionClass( OrdersScheduler::class );
+		$method     = $reflection->getMethod( 'is_scheduled_import_enabled' );
+		$method->setAccessible( true );
+
+		$this->assertFalse(
+			$method->invoke( null ),
+			'Legacy option "yes" (immediate) should be interpreted as scheduled import disabled'
+		);
+	}
+
+	/**
+	 * @testdox is_scheduled_import_enabled prefers new option over legacy option when they conflict.
+	 */
+	public function test_is_scheduled_import_enabled_prefers_new_option(): void {
+		// New option says "not scheduled", legacy says "scheduled" (inverted 'no' = scheduled).
+		// If the new option takes precedence, result should be false.
+		update_option( OrdersScheduler::SCHEDULED_IMPORT_OPTION, 'no' );
+		update_option( OrdersScheduler::LEGACY_IMMEDIATE_IMPORT_OPTION, 'no' );
+
+		$reflection = new \ReflectionClass( OrdersScheduler::class );
+		$method     = $reflection->getMethod( 'is_scheduled_import_enabled' );
+		$method->setAccessible( true );
+
+		$this->assertFalse(
+			$method->invoke( null ),
+			'New option "no" should take precedence over legacy option "no" (which would mean scheduled)'
+		);
+	}
+
 	/**
 	 * Clear any scheduled batch processor actions.
 	 *