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