Commit d1d120953e1 for woocommerce

commit d1d120953e1be6130d8155bcbe74e281b8a10fbc
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Tue Apr 21 19:00:55 2026 +0200

    Schedule sale events on meta change not on product update (#64140)

    * Schedule sale AS events when sale date meta is written via update_post_meta

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Schedule sale AS events when sale date meta is written via update_post_meta

    * Move sale event scheduling to post meta hooks, covering CRUD, direct writes, and deletes

    * Skip sale event rescheduling when invoked from AS sale handlers

    * docs: Update stale comment in wc_scheduled_sales re: meta-hook path

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
    Co-authored-by: Brandon Kraft <public@brandonkraft.com>

diff --git a/plugins/woocommerce/changelog/64140-fix-schedule-sale-events-on-meta-write b/plugins/woocommerce/changelog/64140-fix-schedule-sale-events-on-meta-write
new file mode 100644
index 00000000000..3ab6e8dc553
--- /dev/null
+++ b/plugins/woocommerce/changelog/64140-fix-schedule-sale-events-on-meta-write
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Schedule sale events when sale date meta is written directly, so sales activate on time for importers and integrations that bypass WooCommerce CRUD.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/wc-product-functions.php b/plugins/woocommerce/includes/wc-product-functions.php
index aeec1e846d7..daf0d2d8635 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -771,8 +771,40 @@ function wc_maybe_schedule_product_sale_events( $product_id, $product = null ):
 		wc_schedule_product_sale_events( $product );
 	}
 }
-add_action( 'woocommerce_update_product', 'wc_maybe_schedule_product_sale_events', 10, 2 );
-add_action( 'woocommerce_new_product', 'wc_maybe_schedule_product_sale_events', 10, 2 );
+
+/**
+ * Schedule sale events when sale date meta is added, updated, or deleted.
+ *
+ * Hooks into post meta operations so per-product sale events are kept in sync regardless
+ * of how the meta is written: WooCommerce CRUD, direct update_post_meta() calls from
+ * importers, ERP sync tools, or custom code.
+ *
+ * @since 10.8.0
+ * @param int|int[] $meta_id    Meta ID (or array of IDs for delete).
+ * @param int       $object_id  Post ID.
+ * @param string    $meta_key   Meta key.
+ * @return void
+ */
+function wc_maybe_schedule_sale_events_on_meta_change( $meta_id, $object_id, $meta_key ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
+	if ( '_sale_price_dates_from' !== $meta_key && '_sale_price_dates_to' !== $meta_key ) {
+		return;
+	}
+
+	// Prevent duplicate scheduling when a sale handler's save() rewrites dates already in flight.
+	if ( doing_action( 'wc_product_start_scheduled_sale' ) || doing_action( 'wc_product_end_scheduled_sale' ) ) {
+		return;
+	}
+
+	$post_type = get_post_type( $object_id );
+	if ( 'product' !== $post_type && 'product_variation' !== $post_type ) {
+		return;
+	}
+
+	wc_maybe_schedule_product_sale_events( $object_id );
+}
+add_action( 'added_post_meta', 'wc_maybe_schedule_sale_events_on_meta_change', 10, 3 );
+add_action( 'updated_post_meta', 'wc_maybe_schedule_sale_events_on_meta_change', 10, 3 );
+add_action( 'deleted_post_meta', 'wc_maybe_schedule_sale_events_on_meta_change', 10, 3 );

 /**
  * Function which handles the start and end of scheduled sales via cron.
@@ -812,8 +844,9 @@ function wc_scheduled_sales() {

 			if ( $product ) {
 				wc_apply_sale_state_for_product( $product, 'start' );
-				// Note: wc_apply_sale_state_for_product() calls save(), which triggers
-				// woocommerce_update_product hook, which schedules the end AS event.
+				// Note: wc_apply_sale_state_for_product() calls save(), which writes sale
+				// date meta and triggers wc_maybe_schedule_sale_events_on_meta_change(),
+				// which schedules the end AS event.
 			}

 			$product_util->delete_product_specific_transients( $product ? $product : $product_id );
diff --git a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
index b0f37f5de15..5d538dc69a2 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -370,6 +370,174 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertNotEquals( $original_start, $new_start_action, 'Start action should be rescheduled with new time' );
 	}

+	/**
+	 * @testDox Action Scheduler events are scheduled when sale date meta is written directly via update_post_meta.
+	 */
+	public function test_wc_schedule_product_sale_events_on_direct_meta_write() {
+		$future_start = time() + 3600;
+		$future_end   = time() + 86400;
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 100 );
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->save();
+
+		// Verify no sale events are scheduled yet.
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_start_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'No start action should be scheduled before meta write'
+		);
+
+		// Write sale date meta directly, bypassing WooCommerce CRUD.
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', $future_start );
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', $future_end );
+
+		// Check that AS actions were scheduled via the meta hook.
+		$start_action = as_next_scheduled_action(
+			'wc_product_start_scheduled_sale',
+			array( 'product_id' => $product->get_id() ),
+			'woocommerce-sales'
+		);
+		$end_action   = as_next_scheduled_action(
+			'wc_product_end_scheduled_sale',
+			array( 'product_id' => $product->get_id() ),
+			'woocommerce-sales'
+		);
+
+		$this->assertNotFalse( $start_action, 'Start sale action should be scheduled after direct meta write' );
+		$this->assertNotFalse( $end_action, 'End sale action should be scheduled after direct meta write' );
+	}
+
+	/**
+	 * @testDox Action Scheduler events are scheduled for product variations when sale date meta is written directly.
+	 */
+	public function test_wc_schedule_product_sale_events_on_direct_meta_write_for_variation() {
+		$future_start = time() + 3600;
+		$future_end   = time() + 86400;
+
+		$product      = WC_Helper_Product::create_variation_product();
+		$variations   = $product->get_children();
+		$variation_id = $variations[0];
+
+		$variation = wc_get_product( $variation_id );
+		$variation->set_sale_price( 5 );
+		$variation->save();
+
+		// Write sale date meta directly on the variation.
+		update_post_meta( $variation_id, '_sale_price_dates_from', $future_start );
+		update_post_meta( $variation_id, '_sale_price_dates_to', $future_end );
+
+		$start_action = as_next_scheduled_action(
+			'wc_product_start_scheduled_sale',
+			array( 'product_id' => $variation_id ),
+			'woocommerce-sales'
+		);
+
+		$this->assertNotFalse( $start_action, 'Start sale action should be scheduled for variation after direct meta write' );
+	}
+
+	/**
+	 * @testDox Scheduled sale events are cleared when sale date meta is deleted.
+	 */
+	public function test_wc_schedule_product_sale_events_cleared_on_meta_delete() {
+		$future_start = time() + 3600;
+		$future_end   = time() + 86400;
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( 100 );
+		$product->set_regular_price( 100 );
+		$product->set_sale_price( 50 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $future_start ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', $future_end ) );
+		$product->save();
+
+		// Sanity check: events are scheduled.
+		$this->assertNotFalse(
+			as_next_scheduled_action( 'wc_product_start_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'Start action should be scheduled after save'
+		);
+
+		// Delete sale date meta directly, bypassing WooCommerce CRUD.
+		delete_post_meta( $product->get_id(), '_sale_price_dates_from' );
+		delete_post_meta( $product->get_id(), '_sale_price_dates_to' );
+
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_start_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'Start action should be cleared after sale date meta is deleted'
+		);
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_end_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'End action should be cleared after sale date meta is deleted'
+		);
+	}
+
+	/**
+	 * @testDox Meta hook does not reschedule when sale date meta is written from inside the AS sale start handler.
+	 */
+	public function test_wc_schedule_sale_events_meta_hook_skips_when_inside_as_start_handler() {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$writer = function ( $pid ) {
+			update_post_meta( $pid, '_sale_price_dates_from', time() + 3600 );
+		};
+		add_action( 'wc_product_start_scheduled_sale', $writer, 1, 1 );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'wc_product_start_scheduled_sale', $product->get_id() );
+
+		remove_action( 'wc_product_start_scheduled_sale', $writer, 1 );
+
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_start_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'Meta-hook scheduling should be suppressed while inside the AS sale start handler'
+		);
+	}
+
+	/**
+	 * @testDox Meta hook does not reschedule when sale date meta is written from inside the AS sale end handler.
+	 */
+	public function test_wc_schedule_sale_events_meta_hook_skips_when_inside_as_end_handler() {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$writer = function ( $pid ) {
+			update_post_meta( $pid, '_sale_price_dates_to', time() + 3600 );
+		};
+		add_action( 'wc_product_end_scheduled_sale', $writer, 1, 1 );
+
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+		do_action( 'wc_product_end_scheduled_sale', $product->get_id() );
+
+		remove_action( 'wc_product_end_scheduled_sale', $writer, 1 );
+
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_end_scheduled_sale', array( 'product_id' => $product->get_id() ), 'woocommerce-sales' ),
+			'Meta-hook scheduling should be suppressed while inside the AS sale end handler'
+		);
+	}
+
+	/**
+	 * @testDox Direct meta write on non-product post types does not schedule sale events.
+	 */
+	public function test_wc_schedule_sale_events_ignores_non_product_post_types() {
+		$future_start = time() + 3600;
+
+		$post_id = wp_insert_post(
+			array(
+				'post_title'  => 'Not a product',
+				'post_type'   => 'post',
+				'post_status' => 'publish',
+			)
+		);
+
+		update_post_meta( $post_id, '_sale_price_dates_from', $future_start );
+
+		$this->assertFalse(
+			as_next_scheduled_action( 'wc_product_start_scheduled_sale', array( 'product_id' => $post_id ), 'woocommerce-sales' ),
+			'Sale events should not be scheduled for non-product post types'
+		);
+	}
+
 	/**
 	 * @testdox Guest order uses billing address tax rate when woocommerce_adjust_non_base_location_prices is false.
 	 */