Commit 701db742cbd for woocommerce

commit 701db742cbd1dcc2a396e49b23c922fe2a5a7035
Author: Michael Pretty <prettyboymp@users.noreply.github.com>
Date:   Thu Jul 23 06:43:07 2026 -0400

    Fix scheduled sale price gap before the scheduled-sale event runs (#65551)

    * Fix scheduled sale price gap between the sale boundary and the AS event

    Scheduled sales write the active price into the price prop at save time and at
    the per-product Action Scheduler boundary events, but is_on_sale() is a live
    date check. Between a sale's start/end time elapsing and the next save or AS
    event the stored price can disagree with the dates: the simple-product data
    store reads a stale _price into the price prop, and own-cache extensions (e.g.
    Product Bundles) read a stale cached base price. Variations are unaffected --
    their data store re-derives the price from is_on_sale() on every read. The
    wrong price is shown and charged for up to one AS runner interval.

    Add wc_filter_scheduled_sale_active_price() on woocommerce_product_get_price to
    reconcile get_price() with the live sale dates at read time. Display-layer only
    (view context), no DB writes; the common no-sale case exits after a single prop
    read. Respects third-party prices and leaves custom prices unrelated to
    sale/regular untouched.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Address review findings: TYPE-1, TEST-1

    Fixes from PR review:
    - TYPE-1: Correct @since 10.9.0 -> 11.0.0 (matches the 11.0.0-dev target).
    - TEST-1: Add a started-sale-gap test that actually exercises the
      third-party-price guard. The existing test runs against an active sale,
      so no reconciliation branch fires and it passes via fall-through whether
      or not the guard exists; the new test drives a case where the heal would
      otherwise reconcile and asserts the third-party price still wins.

    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

    * Address review feedback: docblock limitation, single-bound tests

    * Document why already-filtered prices are not healed

    * Move scheduled-sale price heal into an Internal class

    * Address review feedback: simplify reconciler docs

    The class docblock was hard to follow (review feedback on #65551). Trim it
    to the essentials and move the intentionally-uncovered cases next to the
    code they describe: the variable-parent aggregate gap (with the why — those
    values come from the var-prices transient and never pass through this
    filter) onto reconcile_price(), and the custom-_price collision note onto
    its guard.

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/64542-fix-scheduled-sale-display-cart-price-gap b/plugins/woocommerce/changelog/64542-fix-scheduled-sale-display-cart-price-gap
new file mode 100644
index 00000000000..ca62a6f38cd
--- /dev/null
+++ b/plugins/woocommerce/changelog/64542-fix-scheduled-sale-display-cart-price-gap
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix scheduled sales briefly showing and charging the wrong price between the sale start/end time and the scheduled-sale event running.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index cb77d6c0b69..a7bed480c95 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -418,6 +418,7 @@ final class WooCommerce {
 		$container->get( Automattic\WooCommerce\Internal\Orders\PointOfSaleEmailHandler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\POS\POSController::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController::class )->register();
+		$container->get( Automattic\WooCommerce\Internal\ScheduledSalePriceReconciler::class )->register();
 		$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();

 		// Classes inheriting from RestApiControllerBase.
diff --git a/plugins/woocommerce/src/Internal/ScheduledSalePriceReconciler.php b/plugins/woocommerce/src/Internal/ScheduledSalePriceReconciler.php
new file mode 100644
index 00000000000..11efc8ae7e7
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ScheduledSalePriceReconciler.php
@@ -0,0 +1,113 @@
+<?php
+/**
+ * ScheduledSalePriceReconciler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal;
+
+use WC_Product;
+
+/**
+ * Corrects a product's price at read time when a scheduled sale has started or ended
+ * but the stored `_price` has not been updated yet.
+ *
+ * The Action Scheduler events that update `_price` at the sale boundaries can run
+ * late (up to one runner interval); until they do, the stored price disagrees with
+ * the sale dates and the wrong price is shown and charged. The
+ * `woocommerce_product_get_price` filter registered here returns the date-correct
+ * price for that window. It applies in 'view' context only and writes nothing, so
+ * 'edit'-context reads (saves, CRUD, the product edit form, the boundary events)
+ * and the stored `_price` are untouched.
+ * Cases intentionally not reconciled are documented in `reconcile_price()`.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 11.1.0
+ */
+class ScheduledSalePriceReconciler implements RegisterHooksInterface {
+
+	/**
+	 * Register hooks and filters.
+	 */
+	public function register(): void {
+		add_filter( 'woocommerce_product_get_price', array( $this, 'reconcile_price' ), 99, 2 );
+	}
+
+	/**
+	 * Reconcile the active price with the sale schedule when the stored price is stale.
+	 *
+	 * Intentionally not covered: a variable parent's aggregate display (the "From"
+	 * price, on-sale badge, and min/max prices). Those values are served from the
+	 * `wc_var_prices_{id}` transient without passing through this filter, and the
+	 * cache key has no sale-window component, so they can stay stale until the
+	 * boundary event or a save refreshes the cache. Covering them would mean changing
+	 * cache keys or invalidation, not a read-time filter — scoped out along with the
+	 * product lookup table. The gap is display-only: the cart prices the variation
+	 * itself, which re-derives its price from the sale dates on every read. Other
+	 * skipped cases are noted on the guards below.
+	 *
+	 * @internal
+	 *
+	 * @param string     $price   The product's active price.
+	 * @param WC_Product $product The product object.
+	 * @return string The active price, reconciled with the sale schedule when applicable.
+	 */
+	public function reconcile_price( $price, $product ) {
+		if ( ! $product instanceof WC_Product || '' === (string) $price ) {
+			return $price;
+		}
+
+		// Fast path: products with no sale price (the vast majority, plus variable/grouped
+		// parents whose own sale price is empty) can never drift out of sync.
+		$sale_price = $product->get_sale_price( 'edit' );
+		if ( '' === (string) $sale_price ) {
+			return $price;
+		}
+
+		// Only scheduled sales can drift: an unscheduled sale's price is fixed at save time.
+		$date_from = $product->get_date_on_sale_from( 'edit' );
+		$date_to   = $product->get_date_on_sale_to( 'edit' );
+		if ( ! $date_from && ! $date_to ) {
+			return $price;
+		}
+
+		// Require a real discount. The strict `>` (i.e. bail on `<=`) mirrors is_on_sale() so
+		// the resolved price never contradicts the on-sale flag.
+		$regular_price = $product->get_regular_price( 'edit' );
+		if ( '' === (string) $regular_price || (float) $regular_price <= (float) $sale_price ) {
+			return $price;
+		}
+
+		// Respect third-party prices: if another callback already changed the price away from
+		// the stored value, leave it alone. This is deliberate even for ambient transforms
+		// such as currency conversion: by this priority the price is already transformed, and
+		// returning a raw sale/regular price would bypass the transform, which cannot be
+		// re-applied from here. Those stores keep their pre-existing behavior during the gap.
+		// The 'edit' context returns the raw stored price without re-applying this filter, so
+		// there is no recursion.
+		$stored_price = $product->get_price( 'edit' );
+		if ( (string) $price !== (string) $stored_price ) {
+			return $price;
+		}
+
+		// Pure date-window check, intentionally inline rather than calling is_on_sale() to
+		// avoid recursing through plugins that read get_price() from a
+		// woocommerce_product_is_on_sale callback.
+		$now           = time();
+		$within_window = ! ( ( $date_from && $date_from->getTimestamp() > $now ) || ( $date_to && $date_to->getTimestamp() < $now ) );
+
+		// Reconcile only when the stored price is exactly the sale or regular price. A
+		// deliberate custom `_price` that happens to equal one of them is indistinguishable
+		// from a stale value and gets reconciled; any other custom value falls through.
+		if ( ! $within_window && (float) $stored_price === (float) $sale_price ) {
+			return $regular_price;
+		}
+		if ( $within_window && (float) $stored_price === (float) $regular_price ) {
+			return $sale_price;
+		}
+
+		return $price;
+	}
+}
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 4c9ed886c47..2174a058654 100644
--- a/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-product-functions-test.php
@@ -224,7 +224,8 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		// Bypass product after save hook to prevent price change on save.
 		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 5 );

-		$this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() );
+		// The stored _price stays stale until the cron runs (the display heal makes get_price() correct sooner).
+		$this->assertEquals( 100, get_post_meta( $product->get_id(), '_price', true ) );

 		wc_scheduled_sales();

@@ -245,13 +246,272 @@ class WC_Product_Functions_Tests extends \WC_Unit_Test_Case {
 		// Bypass product after save hook to prevent price change on save.
 		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 5 );

-		$this->assertEquals( 50, wc_get_product( $product->get_id() )->get_price() );
+		// The stored _price stays stale until the cron runs (the display heal makes get_price() correct sooner).
+		$this->assertEquals( 50, get_post_meta( $product->get_id(), '_price', true ) );

 		wc_scheduled_sales();

 		$this->assertEquals( 100, wc_get_product( $product->get_id() )->get_price() );
 	}

+	/**
+	 * @testdox An ended scheduled sale displays the regular price before the AS event runs.
+	 */
+	public function test_scheduled_sale_active_price_heals_ended_sale_to_regular(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// End time elapses without the AS event running, so _price stays stale.
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$reread = wc_get_product( $product->get_id() );
+		$this->assertEquals( 50, $reread->get_price(), 'An ended sale should display the regular price.' );
+		$this->assertEquals( 20, get_post_meta( $product->get_id(), '_price', true ), 'The stored _price stays stale until the AS event/cron runs.' );
+		$this->assertStringNotContainsString( '<del', $reread->get_price_html(), 'An ended sale should not render a strikethrough.' );
+	}
+
+	/**
+	 * @testdox A started scheduled sale displays the sale price before the AS event runs.
+	 */
+	public function test_scheduled_sale_active_price_heals_started_sale_to_sale_price(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + 2 * HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// Start time elapses without the AS event running, so _price stays at the regular price.
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$reread = wc_get_product( $product->get_id() );
+		$this->assertEquals( 20, $reread->get_price(), 'A started sale should display the sale price.' );
+		$this->assertStringContainsString( '<del', $reread->get_price_html(), 'A started sale should render a strikethrough.' );
+	}
+
+	/**
+	 * @testdox A started scheduled sale with no end date heals to the sale price.
+	 */
+	public function test_scheduled_sale_active_price_heals_started_sale_with_no_end_date(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// Start time elapses without the AS event running, so _price stays at the regular price.
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$this->assertEquals( 20, wc_get_product( $product->get_id() )->get_price(), 'A started sale with only a start date should display the sale price.' );
+	}
+
+	/**
+	 * @testdox An ended scheduled sale with no start date heals to the regular price.
+	 */
+	public function test_scheduled_sale_active_price_heals_ended_sale_with_no_start_date(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// End time elapses without the AS event running, so _price stays at the sale price.
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$this->assertEquals( 50, wc_get_product( $product->get_id() )->get_price(), 'An ended sale with only an end date should display the regular price.' );
+	}
+
+	/**
+	 * @testdox An active scheduled sale leaves the sale price unchanged.
+	 */
+	public function test_scheduled_sale_active_price_leaves_active_sale_unchanged(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		$this->assertEquals( 20, wc_get_product( $product->get_id() )->get_price(), 'An active sale should display the sale price.' );
+	}
+
+	/**
+	 * @testdox A future scheduled sale leaves the regular price unchanged.
+	 */
+	public function test_scheduled_sale_active_price_leaves_future_sale_unchanged(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + 2 * HOUR_IN_SECONDS ) );
+		$product->save();
+
+		$this->assertEquals( 50, wc_get_product( $product->get_id() )->get_price(), 'A future sale should display the regular price.' );
+	}
+
+	/**
+	 * @testdox A custom active price unrelated to the sale or regular price is left untouched.
+	 */
+	public function test_scheduled_sale_active_price_leaves_custom_price_untouched(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// A third party set a custom active price unrelated to the sale or regular price.
+		update_post_meta( $product->get_id(), '_price', 37 );
+		clean_post_cache( $product->get_id() );
+
+		$this->assertEquals( 37, wc_get_product( $product->get_id() )->get_price(), 'A custom price not equal to sale or regular is left untouched.' );
+	}
+
+	/**
+	 * @testdox A sale with no scheduled dates is never reconciled.
+	 */
+	public function test_scheduled_sale_active_price_ignores_unscheduled_sale(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->save();
+
+		// Stored price disagrees with the on-sale state, but there is no schedule to drift against.
+		update_post_meta( $product->get_id(), '_price', 50 );
+		clean_post_cache( $product->get_id() );
+
+		$this->assertEquals( 50, wc_get_product( $product->get_id() )->get_price(), 'Without sale dates the reconciliation does not run.' );
+	}
+
+	/**
+	 * @testdox A price already changed by another plugin is not overridden.
+	 */
+	public function test_scheduled_sale_active_price_does_not_clobber_third_party_price(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		$product_id = $product->get_id();
+		// Membership-style plugin charges the regular price during an active sale.
+		$callback = function ( $price, $filtered_product ) use ( $product_id ) {
+			return ( $filtered_product instanceof WC_Product && $filtered_product->get_id() === $product_id ) ? '50' : $price;
+		};
+		add_filter( 'woocommerce_product_get_price', $callback, 50, 2 );
+
+		$this->assertEquals( 50, wc_get_product( $product_id )->get_price(), 'A deliberate third-party price is respected, not reverted to the sale price.' );
+
+		remove_filter( 'woocommerce_product_get_price', $callback, 50 );
+	}
+
+	/**
+	 * @testdox A third-party price wins over the heal even when the heal would otherwise fire.
+	 */
+	public function test_scheduled_sale_active_price_respects_third_party_price_during_started_gap(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		// Future sale, so the saved _price is the regular price (50).
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + 2 * HOUR_IN_SECONDS ) );
+		$product->save();
+
+		// Start time elapses without the AS event running: the sale is active but _price is
+		// still 50, so the heal would reconcile 50 -> 20 absent a third-party override.
+		update_post_meta( $product->get_id(), '_sale_price_dates_from', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$product_id = $product->get_id();
+		// Third party sets a deliberate price that is neither the stored value nor the heal target.
+		$callback = function ( $price, $filtered_product ) use ( $product_id ) {
+			return ( $filtered_product instanceof WC_Product && $filtered_product->get_id() === $product_id ) ? '42' : $price;
+		};
+		add_filter( 'woocommerce_product_get_price', $callback, 50, 2 );
+
+		$this->assertEquals(
+			42,
+			wc_get_product( $product_id )->get_price(),
+			'The third-party price wins over the scheduled-sale heal (which would otherwise return 20).'
+		);
+
+		remove_filter( 'woocommerce_product_get_price', $callback, 50 );
+	}
+
+	/**
+	 * @testdox An external product reconciles like a simple product.
+	 */
+	public function test_scheduled_sale_active_price_heals_external_product(): void {
+		$product = new WC_Product_External();
+		$product->set_name( 'External sale product' );
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		$this->assertEquals( 50, wc_get_product( $product->get_id() )->get_price(), 'External products reconcile like simple products.' );
+	}
+
+	/**
+	 * @testdox Variations self-heal at read time and need no reconciliation filter.
+	 */
+	public function test_scheduled_sale_variation_self_heals_on_read(): void {
+		$parent = new WC_Product_Variable();
+		$parent->set_name( 'Variation parent' );
+		$parent->save();
+
+		$variation = new WC_Product_Variation();
+		$variation->set_parent_id( $parent->get_id() );
+		$variation->set_regular_price( '50' );
+		$variation->set_sale_price( '20' );
+		$variation->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$variation->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$variation->save();
+
+		// The variation data store re-derives the price from is_on_sale() on every read, so a
+		// fresh read after the sale ends already reflects the regular price (no filter needed).
+		update_post_meta( $variation->get_id(), '_sale_price_dates_to', time() - 5 );
+		clean_post_cache( $variation->get_id() );
+
+		$this->assertEquals( 50, wc_get_product( $variation->get_id() )->get_price(), 'A freshly read variation reflects the ended sale without the reconciliation filter.' );
+	}
+
+	/**
+	 * @testdox An ended scheduled sale charges the regular price in the cart.
+	 */
+	public function test_scheduled_sale_active_price_charges_regular_in_cart_for_ended_sale(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 50 );
+		$product->set_sale_price( 20 );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ) );
+		$product->save();
+
+		update_post_meta( $product->get_id(), '_sale_price_dates_to', time() - 5 );
+		clean_post_cache( $product->get_id() );
+
+		WC()->cart->empty_cart();
+		WC()->cart->add_to_cart( $product->get_id() );
+		WC()->cart->calculate_totals();
+		$total = WC()->cart->get_cart_contents_total();
+		WC()->cart->empty_cart();
+
+		$this->assertEquals( 50, $total, 'The cart charges the regular price once the sale has ended.' );
+	}
+
 	/**
 	 * @testdox Lookup table is refreshed when scheduled sale starts.
 	 */