Commit f21f1796ae9 for woocommerce

commit f21f1796ae96f1f2783825f97cce4285b8fcf4a8
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Thu Jul 16 15:10:48 2026 +0300

    Fix Quick Edit removing scheduled sale dates (#66648)

    * Preserve sale dates during product Quick Edit

    * Add changelog entry for Quick Edit sale dates

    * Handle invalid scheduled sale prices in Quick Edit

    * test: cover remaining Quick Edit sale schedule branches

    The Quick Edit sale-date tests exercised a sale price change against an
    active and a future schedule, leaving two branches of the new predicate
    uncovered: dates cleared when the sale price is emptied, and dates kept
    when only the regular price changes while the sale stays valid.

    Drive both existing tests from data providers so each branch of the
    predicate is asserted. Verified by mutation: dropping the empty sale
    price clause fails the removed-sale-price case, and restoring the
    original unconditional clearing fails all three preserve cases.

    Refs #29040

diff --git a/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates b/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
new file mode 100644
index 00000000000..92e9a467f6d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve scheduled sale dates when updating product prices with Quick Edit.
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 712c7696851..7129c795ce2 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -395,10 +395,8 @@ class WC_Admin_Post_Types {
 	private function quick_edit_save( $post_id, $product ) {
 		$request_data = $this->request_data();

-		$data_store        = $product->get_data_store();
-		$old_regular_price = $product->get_regular_price();
-		$old_sale_price    = $product->get_sale_price();
-		$input_to_props    = array(
+		$data_store     = $product->get_data_store();
+		$input_to_props = array(
 			'_weight'     => 'weight',
 			'_length'     => 'length',
 			'_width'      => 'width',
@@ -452,33 +450,26 @@ 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' );

 			if ( isset( $request_data['_regular_price'] ) ) {
 				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
 				$new_regular_price = ( '' === $request_data['_regular_price'] ) ? '' : wc_format_decimal( $request_data['_regular_price'] );
 				$product->set_regular_price( $new_regular_price );
-			} else {
-				$new_regular_price = null;
 			}

 			if ( isset( $request_data['_sale_price'] ) ) {
 				// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
 				$new_sale_price = ( '' === $request_data['_sale_price'] ) ? '' : wc_format_decimal( $request_data['_sale_price'] );
 				$product->set_sale_price( $new_sale_price );
-			} else {
-				$new_sale_price = null;
 			}

-			// Handle price - remove dates and set to lowest.
-			$price_changed = false;
-
-			if ( ! is_null( $new_regular_price ) && $new_regular_price !== $old_regular_price ) {
-				$price_changed = true;
-			} elseif ( ! is_null( $new_sale_price ) && $new_sale_price !== $old_sale_price ) {
-				$price_changed = true;
-			}
+			$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 ) {
+			if ( $price_changed && ( '' === $sale_price || $sale_price >= $regular_price ) ) {
 				$product->set_date_on_sale_to( '' );
 				$product->set_date_on_sale_from( '' );
 			}
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
new file mode 100644
index 00000000000..778bdff3f97
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Tests for the WC_Admin_Post_Types class.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Class WC_Admin_Post_Types_Test.
+ */
+class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var WC_Admin_Post_Types
+	 */
+	private $sut;
+
+	/**
+	 * The original request data.
+	 *
+	 * @var array
+	 */
+	private $original_request;
+
+	/**
+	 * The original current user ID.
+	 *
+	 * @var int
+	 */
+	private $original_user_id;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		require_once WC_ABSPATH . 'includes/admin/class-wc-admin-post-types.php';
+
+		$reflection             = new ReflectionClass( WC_Admin_Post_Types::class );
+		$this->sut              = $reflection->newInstanceWithoutConstructor();
+		$this->original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+		$this->original_user_id = get_current_user_id();
+		$administrator_user_id  = $this->factory->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $administrator_user_id );
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		$_REQUEST = $this->original_request;
+		wp_set_current_user( $this->original_user_id );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Quick Edit preserves sale dates when a valid product price changes.
+	 * @dataProvider valid_price_changes_provider
+	 *
+	 * @param int    $start_offset Sale start offset from the current time.
+	 * @param int    $end_offset Sale end offset from the current time.
+	 * @param string $new_regular_price New regular price.
+	 * @param string $new_sale_price New sale price.
+	 */
+	public function test_quick_edit_preserves_sale_dates_when_prices_change( int $start_offset, int $end_offset, string $new_regular_price, string $new_sale_price ): void {
+		$now                = time();
+		$product            = WC_Helper_Product::create_simple_product();
+		$sale_date_from     = gmdate( 'Y-m-d H:i:s', $now + $start_offset );
+		$sale_date_to       = gmdate( 'Y-m-d H:i:s', $now + $end_offset );
+		$original_sale_from = wc_string_to_datetime( $sale_date_from );
+		$original_sale_to   = wc_string_to_datetime( $sale_date_to );
+
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '80' );
+		$product->set_date_on_sale_from( $sale_date_from );
+		$product->set_date_on_sale_to( $sale_date_to );
+		$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() );
+		$updated_sale_from = $updated_product->get_date_on_sale_from( 'edit' );
+		$updated_sale_to   = $updated_product->get_date_on_sale_to( 'edit' );
+
+		$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->assertInstanceOf( WC_DateTime::class, $updated_sale_from, 'Quick Edit should preserve the sale start date.' );
+		$this->assertInstanceOf( WC_DateTime::class, $updated_sale_to, 'Quick Edit should preserve the sale end date.' );
+		$this->assertSame( $original_sale_from->getTimestamp(), $updated_sale_from->getTimestamp(), 'The sale start date should remain unchanged.' );
+		$this->assertSame( $original_sale_to->getTimestamp(), $updated_sale_to->getTimestamp(), 'The sale end date should remain unchanged.' );
+	}
+
+	/**
+	 * @testdox Quick Edit clears sale dates when a price change ends the sale.
+	 * @dataProvider sale_ending_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_sale_dates_when_sale_ends( 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() - 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( '', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should leave the product without a sale price.' );
+		$this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the start date when the sale ends.' );
+		$this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the end date when the sale ends.' );
+	}
+
+	/**
+	 * Provides valid price changes for active and future sale schedules.
+	 *
+	 * @return array<string, array{int, int, string, string}>
+	 */
+	public static function valid_price_changes_provider(): array {
+		return array(
+			'active schedule with sale price change'    => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '100', '70' ),
+			'future schedule with sale price change'    => array( DAY_IN_SECONDS, 2 * DAY_IN_SECONDS, '100', '70' ),
+			'active schedule with regular price change' => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '90', '80' ),
+		);
+	}
+
+	/**
+	 * Provides price changes that end an active sale.
+	 *
+	 * @return array<string, array{string, string}>
+	 */
+	public static function sale_ending_price_changes_provider(): array {
+		return array(
+			'regular price no longer above sale price' => array( '70', '80' ),
+			'sale price removed'                       => array( '100', '' ),
+		);
+	}
+}