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