Commit 0447abbd999 for woocommerce

commit 0447abbd99924b72ab4f43c67e2379a027ff5bfa
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Fri Jul 17 19:52:14 2026 +0300

    Fix Quick Edit keeping expired sale dates when prices change (#66726)

    * Fix Quick Edit keeping expired sale dates when prices change

    A product whose scheduled sale has ended keeps its sale price and both
    sale date metas, with only _price reset to the regular price. The date
    guard introduced for the Quick Edit sale-schedule fix only cleared the
    dates when the sale price was removed or no longer below the regular
    price, so entering a new valid sale price left the expired end date in
    place. The save then looked successful while is_on_sale() stayed false
    and _price synced back to the regular price, silently never activating
    the new sale.

    Treat an already-ended schedule as stale: clear both sale dates on any
    price change when date_on_sale_to is in the past, matching the
    pre-existing behavior where the new sale takes effect immediately. The
    predicate mirrors the one used by the Bulk Edit fix so both admin
    surfaces stay consistent.

    * Add changelog entry for Quick Edit expired sale dates fix

    * Fix spurious Quick Edit price change detection with display filters

    The Quick Edit form is prefilled from view-context price getters, where
    the woocommerce_product_get_regular_price and _sale_price filters apply,
    while quick_edit_save() compared the submission against raw edit-context
    values. Any extension reshaping prices for display (currency switchers,
    wholesale, dynamic pricing) made the strict comparison see a change on
    every save, so a stock-only edit on a product with an ended sale
    schedule cleared the dates and reactivated the stale sale price.

    Read the old prices in view context, matching the pipeline that filled
    the form, and normalize both sides with wc_format_decimal() so the gate
    only fires when the submission differs from what the form displayed.

    * Restrict Quick Edit price change detection to submitted fields

    A price field absent from the request never runs its setter, yet the
    comparison still evaluated it: the stored edit-context value against the
    filtered view-context value. With a display filter reshaping prices, a
    crafted or plugin-generated inline-save request omitting the price
    fields would register a spurious price change and clear an expired sale
    schedule.

    Gate each side of the comparison on the field being present in the
    request, so only a submitted field can register as changed. Behavior is
    unchanged for browser-driven Quick Edits, which always submit both
    fields. Verified by mutation: removing the isset gates fails the new
    no-price-fields test.

    * Harden Quick Edit price normalization against filter output

    The view-context price reads pass woocommerce_product_get_regular_price
    and _sale_price filter output straight into wc_format_decimal(), which
    fatals on non-Stringable objects and returns arrays unchanged, forcing
    a spurious price change on every save. Default formatting also left
    trailing-zero variants ('100' vs '100.00') unequal, the same
    false-positive class the comparison fix closed.

    Extract a normalize_price_for_comparison() helper that guards with
    is_scalar() before formatting and trims trailing zeros on both sides,
    per the abstract-wc-order convention. Verified by mutation: removing
    the guard fatals the new non-scalar filter test with a TypeError.

diff --git a/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates b/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
new file mode 100644
index 00000000000..497b4e70355
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Clear expired scheduled sale dates during Quick Edit so a new sale price takes effect immediately.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
index 0f469e36371..e54dd0b1717 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -450,8 +450,10 @@ class WC_Admin_Post_Types {
 		$product->set_featured( isset( $request_data['_featured'] ) );

 		if ( $product->is_type( ProductType::SIMPLE ) || $product->is_type( ProductType::EXTERNAL ) ) {
-			$old_regular_price = $product->get_regular_price( 'edit' );
-			$old_sale_price    = $product->get_sale_price( 'edit' );
+			// The Quick Edit form is prefilled with view-context (filtered) prices, so detecting
+			// an actual edit requires comparing the submission against those same values.
+			$old_regular_price = $this->normalize_price_for_comparison( $product->get_regular_price() );
+			$old_sale_price    = $this->normalize_price_for_comparison( $product->get_sale_price() );

 			if ( isset( $request_data['_regular_price'] ) ) {
 				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
@@ -467,9 +469,16 @@ class WC_Admin_Post_Types {

 			$regular_price = $product->get_regular_price( 'edit' );
 			$sale_price    = $product->get_sale_price( 'edit' );
-			$price_changed = $regular_price !== $old_regular_price || $sale_price !== $old_sale_price;

-			if ( $price_changed && ( '' === $sale_price || $sale_price >= $regular_price ) ) {
+			// Only a submitted field can be a changed field.
+			$regular_price_changed = isset( $request_data['_regular_price'] ) && $this->normalize_price_for_comparison( $regular_price ) !== $old_regular_price;
+			$sale_price_changed    = isset( $request_data['_sale_price'] ) && $this->normalize_price_for_comparison( $sale_price ) !== $old_sale_price;
+
+			$price_changed  = $regular_price_changed || $sale_price_changed;
+			$sale_date_to   = $product->get_date_on_sale_to( 'edit' );
+			$sale_has_ended = $sale_date_to && $sale_date_to->getTimestamp() < time();
+
+			if ( $price_changed && ( '' === $sale_price || $sale_price >= $regular_price || $sale_has_ended ) ) {
 				$product->set_date_on_sale_to( '' );
 				$product->set_date_on_sale_from( '' );
 			}
@@ -515,6 +524,20 @@ class WC_Admin_Post_Types {
 		do_action( 'woocommerce_product_quick_edit_save', $product );
 	}

+	/**
+	 * Normalize a price value for change detection during Quick Edit.
+	 *
+	 * View-context prices pass through the woocommerce_product_get_regular_price and
+	 * woocommerce_product_get_sale_price filters, which may return non-scalar values
+	 * or differently formatted numbers.
+	 *
+	 * @param mixed $price Raw price value.
+	 * @return string Normalized price string.
+	 */
+	private function normalize_price_for_comparison( $price ): string {
+		return wc_format_decimal( is_scalar( $price ) ? (string) $price : '', false, true );
+	}
+
 	/**
 	 * Bulk edit.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index 778bdff3f97..f73f89e9ae5 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -137,6 +137,199 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the end date when the sale ends.' );
 	}

+	/**
+	 * @testdox Quick Edit clears expired sale dates when prices change so the new sale takes effect.
+	 * @dataProvider expired_schedule_price_changes_provider
+	 *
+	 * @param string $new_regular_price New regular price.
+	 * @param string $new_sale_price New sale price.
+	 */
+	public function test_quick_edit_clears_expired_sale_dates_when_prices_change( string $new_regular_price, string $new_sale_price ): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+		$product->save();
+
+		$_REQUEST = array(
+			'woocommerce_quick_edit'       => '1',
+			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+			'_regular_price'               => $new_regular_price,
+			'_sale_price'                  => $new_sale_price,
+			'_stock_status'                => 'instock',
+		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
+
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertSame( $new_regular_price, $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
+		$this->assertSame( $new_sale_price, $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
+		$this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the expired sale start date.' );
+		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the expired sale end date.' );
+		$this->assertTrue( $updated_product->is_on_sale( 'edit' ), 'The product should be on sale once the expired schedule is cleared.' );
+		$this->assertSame( $new_sale_price, $updated_product->get_price( 'edit' ), 'The active price should be the new sale price.' );
+	}
+
+	/**
+	 * @testdox Quick Edit preserves expired sale dates when prices are not edited, even if display filters reformat prices.
+	 */
+	public function test_quick_edit_preserves_expired_sale_dates_when_prices_not_edited(): void {
+		// Simulates extensions (currency switchers, wholesale, dynamic pricing) that filter
+		// prices in view context, reshaping the values the Quick Edit form is prefilled with.
+		$format_price = function ( $value ) {
+			return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
+		};
+		add_filter( 'woocommerce_product_get_regular_price', $format_price );
+		add_filter( 'woocommerce_product_get_sale_price', $format_price );
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+		$product->save();
+
+		// A stock-only Quick Edit: the price inputs hold the filtered display values, submitted back unchanged.
+		$_REQUEST = array(
+			'woocommerce_quick_edit'       => '1',
+			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+			'_regular_price'               => '100.00',
+			'_sale_price'                  => '80.00',
+			'_stock_status'                => 'outofstock',
+		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
+
+		remove_filter( 'woocommerce_product_get_regular_price', $format_price );
+		remove_filter( 'woocommerce_product_get_sale_price', $format_price );
+
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertSame( '100.00', $updated_product->get_regular_price( 'edit' ), 'The submitted display-formatted regular price is what gets persisted.' );
+		$this->assertSame( '80.00', $updated_product->get_sale_price( 'edit' ), 'The submitted display-formatted sale price is what gets persisted.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price was edited.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price was edited.' );
+		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by an edit that did not touch prices.' );
+	}
+
+	/**
+	 * @testdox Quick Edit preserves expired sale dates when a converting display filter round-trips unedited prices.
+	 */
+	public function test_quick_edit_preserves_expired_sale_dates_with_converting_filter(): void {
+		// Simulates a value-converting filter (currency switcher, dynamic pricing) that is
+		// active during both the form render and the save request.
+		$convert_price = function ( $value ) {
+			return '' === $value || null === $value ? $value : (string) ( 2 * (float) $value );
+		};
+		add_filter( 'woocommerce_product_get_regular_price', $convert_price );
+		add_filter( 'woocommerce_product_get_sale_price', $convert_price );
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+		$product->save();
+
+		// A stock-only Quick Edit: the form displayed the converted values and submits them back unchanged.
+		$_REQUEST = array(
+			'woocommerce_quick_edit'       => '1',
+			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+			'_regular_price'               => '200',
+			'_sale_price'                  => '160',
+			'_stock_status'                => 'outofstock',
+		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
+
+		remove_filter( 'woocommerce_product_get_regular_price', $convert_price );
+		remove_filter( 'woocommerce_product_get_sale_price', $convert_price );
+
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date under a converting display filter.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date under a converting display filter.' );
+		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a round-trip of converted display prices.' );
+	}
+
+	/**
+	 * @testdox Quick Edit survives a display filter returning a non-scalar value.
+	 */
+	public function test_quick_edit_survives_non_scalar_price_filter(): void {
+		$break_price = function () {
+			return new stdClass();
+		};
+		add_filter( 'woocommerce_product_get_regular_price', $break_price );
+		add_filter( 'woocommerce_product_get_sale_price', $break_price );
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->save();
+
+		$_REQUEST = array(
+			'woocommerce_quick_edit'       => '1',
+			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+			'_regular_price'               => '90',
+			'_sale_price'                  => '70',
+			'_stock_status'                => 'instock',
+		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
+
+		remove_filter( 'woocommerce_product_get_regular_price', $break_price );
+		remove_filter( 'woocommerce_product_get_sale_price', $break_price );
+
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertSame( '90', $updated_product->get_regular_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
+		$this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
+	}
+
+	/**
+	 * @testdox Quick Edit preserves expired sale dates when price fields are absent from the request.
+	 */
+	public function test_quick_edit_preserves_expired_sale_dates_when_price_fields_not_submitted(): void {
+		$format_price = function ( $value ) {
+			return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
+		};
+		add_filter( 'woocommerce_product_get_regular_price', $format_price );
+		add_filter( 'woocommerce_product_get_sale_price', $format_price );
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
+		$product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+		$product->save();
+
+		$_REQUEST = array(
+			'woocommerce_quick_edit'       => '1',
+			'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+			'_stock_status'                => 'instock',
+		);
+
+		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
+
+		remove_filter( 'woocommerce_product_get_regular_price', $format_price );
+		remove_filter( 'woocommerce_product_get_sale_price', $format_price );
+
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertSame( '100', $updated_product->get_regular_price( 'edit' ), 'An absent regular price field should leave the stored regular price untouched.' );
+		$this->assertSame( '80', $updated_product->get_sale_price( 'edit' ), 'An absent sale price field should leave the stored sale price untouched.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price fields were submitted.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price fields were submitted.' );
+		$this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a request without price fields.' );
+	}
+
 	/**
 	 * Provides valid price changes for active and future sale schedules.
 	 *
@@ -150,6 +343,18 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Provides price changes for a product whose scheduled sale has already ended.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public static function expired_schedule_price_changes_provider(): array {
+		return array(
+			'expired schedule with new sale price'       => array( '100', '70' ),
+			'expired schedule with regular price change' => array( '90', '80' ),
+		);
+	}
+
 	/**
 	 * Provides price changes that end an active sale.
 	 *