Commit 7b78e2df7e for woocommerce

commit 7b78e2df7e7c64aa5d8d703d295139e80654a023
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date:   Mon Dec 15 15:44:45 2025 -0500

    Fix Scheduled Sales dates logic (#62115)

    * Implement scheduled sale events for products using Action Scheduler

    This update introduces functions to schedule and handle product sale events based on defined sale dates, ensuring that sales start and end at the correct times. The new functions include `wc_schedule_product_sale_events`, `wc_apply_sale_state_for_product`, `wc_handle_product_start_scheduled_sale`, and `wc_handle_product_end_scheduled_sale`. Additionally, tests have been added to verify that actions are scheduled correctly when products are saved with sale dates, and that existing actions are cleared when sale dates change.

    Also, a minor fix was made in the product data store to adjust the sale price query condition.

    * Refactor product sale event handling and improve price synchronization

    This update refines the handling of scheduled sale events for products by ensuring that existing actions are cleared before scheduling new ones. It introduces checks to skip product types with derived prices and adds workarounds for updating the `_price` meta directly when only the price prop changes. Additionally, a minor adjustment was made to the sale price query condition in the product data store to ensure proper filtering. These changes enhance the reliability of sale event management and price synchronization for variable products.

diff --git a/plugins/woocommerce/changelog/62115-wooplug-949-woocommerce_scheduled_sales-hook-not-changing-sale-states b/plugins/woocommerce/changelog/62115-wooplug-949-woocommerce_scheduled_sales-hook-not-changing-sale-states
new file mode 100644
index 0000000000..b72fd1e35c
--- /dev/null
+++ b/plugins/woocommerce/changelog/62115-wooplug-949-woocommerce_scheduled_sales-hook-not-changing-sale-states
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix scheduled sales starting/ending at wrong times by using per-product Action Scheduler events instead of relying solely on daily cron.
\ 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 31e635573d..e4cc65cdd4 100644
--- a/plugins/woocommerce/includes/wc-product-functions.php
+++ b/plugins/woocommerce/includes/wc-product-functions.php
@@ -548,8 +548,224 @@ function wc_get_formatted_variation( $variation, $flat = false, $include_names =
 	return $return;
 }

+/**
+ * Schedule start/end sale actions for a product based on its sale dates.
+ *
+ * Uses Action Scheduler to fire events at the exact sale start/end times,
+ * rather than relying on the daily cron.
+ *
+ * @since 10.5.0
+ * @param WC_Product $product Product object.
+ * @return void
+ */
+function wc_schedule_product_sale_events( WC_Product $product ): void {
+	$product_id = $product->get_id();
+	$date_from  = $product->get_date_on_sale_from( 'edit' );
+	$date_to    = $product->get_date_on_sale_to( 'edit' );
+
+	if ( $date_from ) {
+		$start_ts = $date_from->getTimestamp();
+		if ( $start_ts > time() ) {
+			as_schedule_single_action( // @phpstan-ignore function.notFound
+				$start_ts,
+				'wc_product_start_scheduled_sale',
+				array( 'product_id' => $product_id ),
+				'woocommerce-sales'
+			);
+		}
+	}
+
+	if ( $date_to ) {
+		$end_ts = $date_to->getTimestamp();
+		if ( $end_ts > time() ) {
+			as_schedule_single_action( // @phpstan-ignore function.notFound
+				$end_ts,
+				'wc_product_end_scheduled_sale',
+				array( 'product_id' => $product_id ),
+				'woocommerce-sales'
+			);
+		}
+	}
+}
+
+/**
+ * Apply the expected sale state for a product.
+ *
+ * This is a shared helper used by both the per-product Action Scheduler
+ * callbacks and the daily cron safety net.
+ *
+ * @since 10.5.0
+ * @param WC_Product $product Product object.
+ * @param string     $mode    'start' or 'end'.
+ * @return void
+ */
+function wc_apply_sale_state_for_product( WC_Product $product, string $mode ): void {
+	$product_id = $product->get_id();
+
+	if ( 'start' === $mode ) {
+		$sale_price = $product->get_sale_price( 'edit' );
+		if ( $sale_price ) {
+			$product->set_price( $sale_price );
+			$product->save();
+
+			// Workaround: `_price` is not in `meta_key_to_props` mapping and only syncs
+			// when date/price props change in `handle_updated_props()`. Since we only
+			// changed `price` prop, we must update `_price` meta directly.
+			// See comment in `WC_Product_Data_Store_CPT::handle_updated_props()`.
+			update_post_meta( $product_id, '_price', $sale_price );
+		}
+	} elseif ( 'end' === $mode ) {
+		$regular_price = $product->get_regular_price( 'edit' );
+		$product->set_price( $regular_price );
+
+		$product->save();
+
+		// Workaround: see above.
+		update_post_meta( $product_id, '_price', $regular_price );
+	}
+
+	wc_delete_product_transients( $product_id );
+
+	// Sync parent variable product price range if this is a variation.
+	if ( $product->is_type( 'variation' ) ) {
+		$parent_id = $product->get_parent_id();
+		if ( $parent_id ) {
+			WC_Product_Variable::sync( $parent_id );
+		}
+	}
+}
+
+/**
+ * Handle scheduled sale start for a product.
+ *
+ * This is the Action Scheduler callback that fires at the exact sale start time.
+ *
+ * @since 10.5.0
+ * @param int $product_id Product ID.
+ * @return void
+ */
+function wc_handle_product_start_scheduled_sale( $product_id ): void {
+	$product = wc_get_product( $product_id );
+	if ( ! $product ) {
+		return;
+	}
+
+	// Skip product types with derived prices.
+	if ( $product->is_type( array( 'variable', 'grouped' ) ) ) {
+		return;
+	}
+
+	// Verify sale should still start (dates/price might have changed since scheduling).
+	if ( ! $product->get_sale_price( 'edit' ) ) {
+		return;
+	}
+
+	$now       = time();
+	$date_from = $product->get_date_on_sale_from( 'edit' );
+	$date_to   = $product->get_date_on_sale_to( 'edit' );
+
+	if ( $date_from && $date_from->getTimestamp() > $now ) {
+		return;
+	}
+
+	if ( $date_to && $date_to->getTimestamp() < $now ) {
+		return;
+	}
+
+	if ( (float) $product->get_price( 'edit' ) === (float) $product->get_sale_price( 'edit' ) ) {
+		return;
+	}
+
+	wc_apply_sale_state_for_product( $product, 'start' );
+}
+add_action( 'wc_product_start_scheduled_sale', 'wc_handle_product_start_scheduled_sale' );
+
+/**
+ * Handle scheduled sale end for a product.
+ *
+ * This is the Action Scheduler callback that fires at the exact sale end time.
+ *
+ * @since 10.5.0
+ * @param int $product_id Product ID.
+ * @return void
+ */
+function wc_handle_product_end_scheduled_sale( $product_id ): void {
+	$product = wc_get_product( $product_id );
+	if ( ! $product ) {
+		return;
+	}
+
+	// Skip product types with derived prices.
+	if ( $product->is_type( array( 'variable', 'grouped' ) ) ) {
+		return;
+	}
+
+	$now     = time();
+	$date_to = $product->get_date_on_sale_to( 'edit' );
+
+	if ( $date_to && $date_to->getTimestamp() > $now ) {
+		return;
+	}
+
+	if ( (float) $product->get_price( 'edit' ) === (float) $product->get_regular_price( 'edit' ) ) {
+		return;
+	}
+
+	wc_apply_sale_state_for_product( $product, 'end' );
+}
+add_action( 'wc_product_end_scheduled_sale', 'wc_handle_product_end_scheduled_sale' );
+
+/**
+ * Schedule sale events when a product is saved with sale dates.
+ *
+ * @since 10.5.0
+ * @param int             $product_id Product ID.
+ * @param WC_Product|null $product    Product object (optional).
+ * @return void
+ */
+function wc_maybe_schedule_product_sale_events( $product_id, $product = null ): void {
+	if ( ! $product ) {
+		$product = wc_get_product( $product_id );
+		if ( ! $product ) {
+			return;
+		}
+	}
+
+	$product_id = $product->get_id();
+
+	// Always clear existing events first.
+	as_unschedule_all_actions( 'wc_product_start_scheduled_sale', array( 'product_id' => $product_id ), 'woocommerce-sales' ); // @phpstan-ignore function.notFound
+	as_unschedule_all_actions( 'wc_product_end_scheduled_sale', array( 'product_id' => $product_id ), 'woocommerce-sales' ); // @phpstan-ignore function.notFound
+
+	$date_from = $product->get_date_on_sale_from( 'edit' );
+	$date_to   = $product->get_date_on_sale_to( 'edit' );
+
+	if ( $date_from || $date_to ) {
+		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 );
+
 /**
  * Function which handles the start and end of scheduled sales via cron.
+ *
+ * Previously, this daily cron was the only mechanism for starting/ending scheduled
+ * sales, which caused timing issues - sales could be "a day off" depending on when
+ * WP-Cron ran. Now, per-product Action Scheduler events fire at exact sale times.
+ *
+ * This function now acts as a safety net to:
+ * 1. Catch any products missed by the per-product Action Scheduler events
+ * 2. Handle products created before the AS events were introduced
+ *
+ * This function is kept for backwards compatibility. Extenders may hook into the
+ * `woocommerce_scheduled_sales` cron event or the before/after hooks fired within.
+ *
+ * Note: The before/after hooks (wc_before_products_starting_sales, etc.) only fire
+ * when this cron finds products to process. If per-product AS events handled sales
+ * on time, these hooks may not fire.
+ *
+ * @since 3.0.0
  */
 function wc_scheduled_sales() {
 	$data_store = WC_Data_Store::load( 'product' );
@@ -562,27 +778,19 @@ function wc_scheduled_sales() {
 	if ( $product_ids ) {
 		$must_refresh_transient = true;
 		do_action( 'wc_before_products_starting_sales', $product_ids );
+
 		foreach ( $product_ids as $product_id ) {
 			$product = wc_get_product( $product_id );

 			if ( $product ) {
-				$sale_price = $product->get_sale_price();
-
-				if ( $sale_price ) {
-					$product->set_price( $sale_price );
-					$product->set_date_on_sale_from( '' );
-				} else {
-					$product->set_date_on_sale_to( '' );
-					$product->set_date_on_sale_from( '' );
-				}
-
-				$product->save();
+				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.
 			}

 			$product_util->delete_product_specific_transients( $product ? $product : $product_id );
 		}
 		do_action( 'wc_after_products_starting_sales', $product_ids );
-
 		delete_transient( 'wc_products_onsale' );
 	}

@@ -591,22 +799,17 @@ function wc_scheduled_sales() {
 	if ( $product_ids ) {
 		$must_refresh_transient = true;
 		do_action( 'wc_before_products_ending_sales', $product_ids );
+
 		foreach ( $product_ids as $product_id ) {
 			$product = wc_get_product( $product_id );

 			if ( $product ) {
-				$regular_price = $product->get_regular_price();
-				$product->set_price( $regular_price );
-				$product->set_sale_price( '' );
-				$product->set_date_on_sale_to( '' );
-				$product->set_date_on_sale_from( '' );
-				$product->save();
+				wc_apply_sale_state_for_product( $product, 'end' );
 			}

 			$product_util->delete_product_specific_transients( $product ? $product : $product_id );
 		}
 		do_action( 'wc_after_products_ending_sales', $product_ids );
-
 		delete_transient( 'wc_products_onsale' );
 	}

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 cba471b7ee..b93ac13ab8 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -5,6 +5,8 @@
  * @package WooCommerce\Tests\Functions\Stock
  */

+declare( strict_types = 1 );
+
 use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
 use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;

@@ -241,6 +243,73 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() );
 	}

+	/**
+	 * @testDox Action Scheduler events are scheduled when product with sale dates is saved.
+	 */
+	public function test_wc_schedule_product_sale_events_on_save() {
+		$future_start = time() + 3600;  // 1 hour from now.
+		$future_end   = time() + 86400; // 24 hours from now.
+
+		$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();
+
+		// Check that AS actions were scheduled.
+		$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' );
+		$this->assertNotFalse( $end_action, 'End sale action should be scheduled' );
+	}
+
+	/**
+	 * @testDox Existing AS events are cleared when product sale dates change.
+	 */
+	public function test_wc_schedule_product_sale_events_clears_existing() {
+		$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();
+
+		$original_start = as_next_scheduled_action(
+			'wc_product_start_scheduled_sale',
+			array( 'product_id' => $product->get_id() ),
+			'woocommerce-sales'
+		);
+
+		// Update the sale dates.
+		$new_start = time() + 7200; // 2 hours from now.
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', $new_start ) );
+		$product->save();
+
+		$new_start_action = as_next_scheduled_action(
+			'wc_product_start_scheduled_sale',
+			array( 'product_id' => $product->get_id() ),
+			'woocommerce-sales'
+		);
+
+		// The timestamp should have changed.
+		$this->assertNotEquals( $original_start, $new_start_action, 'Start action should be rescheduled with new time' );
+	}
+
 	/**
 	 * @testdox Guest order uses billing address tax rate when woocommerce_adjust_non_base_location_prices is false.
 	 */