Commit 56465145b19 for woocommerce

commit 56465145b19f73af3fc6f00630e27bfab501cd55
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Aug 3 16:25:24 2026 +0700

    Fix: prevent invalid date in Quick Edit affect sale schedule (#67052)

    * fix: preserve Quick Edit sale schedules

    * chore: add Quick Edit sale schedule changelog

    * fix: strictly validate Quick Edit sale dates

diff --git a/plugins/woocommerce/changelog/fix-67048-quick-edit-sale-date-validation b/plugins/woocommerce/changelog/fix-67048-quick-edit-sale-date-validation
new file mode 100644
index 00000000000..cdce3480a72
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67048-quick-edit-sale-date-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent invalid Quick Edit sale dates and unavailable datepicker scripts from corrupting product pricing schedules.
diff --git a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
index eee4e8880e2..86d94b95722 100644
--- a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
+++ b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
@@ -2,6 +2,10 @@
 jQuery(
 	function( $ ) {
 		function init_sale_datepickers( $row ) {
+			if ( ! $.fn.datepicker ) {
+				return;
+			}
+
 			var $date_from = $row.find( 'input[name="_sale_price_dates_from"]' ),
 				$date_to   = $row.find( 'input[name="_sale_price_dates_to"]' );

diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 54367f172b0..b9830c3bcbd 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -519,7 +519,8 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :

 			// Products.
 			if ( in_array( $screen_id, array( 'edit-product' ) ) ) {
-				wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'jquery-ui-datepicker', 'woocommerce_admin' ), $version, false );
+				wp_enqueue_script( 'jquery-ui-datepicker' );
+				wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'woocommerce_admin' ), $version, false );

 				$params = array(
 					'strings' => array(
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 adcd8af7472..21fde9f9e92 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -11,6 +11,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
 use Automattic\WooCommerce\Utilities\NumberUtil;
+use Automattic\WooCommerce\Utilities\TimeUtil;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -463,41 +464,43 @@ class WC_Admin_Post_Types {
 				$product->set_sale_price( $sale_price );
 			}

-			// Match the full product editor's date parsing and site-timezone behavior.
-			if ( isset( $request_data['_sale_price_dates_from'] ) ) {
-				$date_on_sale_from = '';
-				if ( is_string( $request_data['_sale_price_dates_from'] ) ) {
-					/**
-					 * Sanitized sale start date.
-					 *
-					 * @var string $date_on_sale_from
-					 */
-					$date_on_sale_from = wc_clean( wp_unslash( $request_data['_sale_price_dates_from'] ) );
-				}
-
-				if ( ! empty( $date_on_sale_from ) ) {
-					$date_on_sale_from = date( 'Y-m-d 00:00:00', (int) strtotime( $date_on_sale_from ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+			// Parse valid dates using the full product editor's site-timezone behavior.
+			$submitted_sale_date_from = $request_data['_sale_price_dates_from'] ?? null;
+			if ( is_string( $submitted_sale_date_from ) ) {
+				/**
+				 * Submitted sale start date.
+				 *
+				 * @var string $date_on_sale_from
+				 */
+				$date_on_sale_from = wp_unslash( $submitted_sale_date_from );
+
+				if ( '' === $date_on_sale_from ) {
+					$product->set_date_on_sale_from( '' );
+				} elseif ( TimeUtil::is_valid_date( $date_on_sale_from, 'Y-m-d' ) ) {
+					$timestamp = strtotime( $date_on_sale_from );
+					if ( false !== $timestamp ) {
+						$product->set_date_on_sale_from( date( 'Y-m-d 00:00:00', $timestamp ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+					}
 				}
-
-				$product->set_date_on_sale_from( $date_on_sale_from );
 			}

-			if ( isset( $request_data['_sale_price_dates_to'] ) ) {
-				$date_on_sale_to = '';
-				if ( is_string( $request_data['_sale_price_dates_to'] ) ) {
-					/**
-					 * Sanitized sale end date.
-					 *
-					 * @var string $date_on_sale_to
-					 */
-					$date_on_sale_to = wc_clean( wp_unslash( $request_data['_sale_price_dates_to'] ) );
-				}
-
-				if ( ! empty( $date_on_sale_to ) ) {
-					$date_on_sale_to = date( 'Y-m-d 23:59:59', (int) strtotime( $date_on_sale_to ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+			$submitted_sale_date_to = $request_data['_sale_price_dates_to'] ?? null;
+			if ( is_string( $submitted_sale_date_to ) ) {
+				/**
+				 * Submitted sale end date.
+				 *
+				 * @var string $date_on_sale_to
+				 */
+				$date_on_sale_to = wp_unslash( $submitted_sale_date_to );
+
+				if ( '' === $date_on_sale_to ) {
+					$product->set_date_on_sale_to( '' );
+				} elseif ( TimeUtil::is_valid_date( $date_on_sale_to, 'Y-m-d' ) ) {
+					$timestamp = strtotime( $date_on_sale_to );
+					if ( false !== $timestamp ) {
+						$product->set_date_on_sale_to( date( 'Y-m-d 23:59:59', $timestamp ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+					}
 				}
-
-				$product->set_date_on_sale_to( $date_on_sale_to );
 			}
 		}

diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
index 4fa1302a7bf..0a23cf9de64 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
@@ -30,10 +30,30 @@ class WC_Admin_Assets_Test extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		unset( $_GET['page'] );
 		wp_dequeue_script( 'woocommerce_admin' );
+		wp_dequeue_script( 'woocommerce_quick-edit' );
+		wp_dequeue_script( 'jquery-ui-datepicker' );
 		wp_dequeue_script( 'heartbeat' );
 		parent::tearDown();
 	}

+	/**
+	 * @testdox Quick Edit remains loadable when the optional datepicker is unavailable.
+	 */
+	public function test_quick_edit_does_not_depend_on_datepicker(): void {
+		set_current_screen();
+		$screen            = get_current_screen();
+		$screen->id        = 'edit-product';
+		$screen->base      = 'edit';
+		$screen->post_type = 'product';
+
+		$this->sut->admin_scripts();
+
+		$quick_edit = wp_scripts()->registered['woocommerce_quick-edit'];
+
+		$this->assertNotContains( 'jquery-ui-datepicker', $quick_edit->deps, 'Quick Edit should load even when another plugin deregisters the datepicker.' );
+		$this->assertTrue( wp_script_is( 'jquery-ui-datepicker', 'enqueued' ), 'The datepicker should still be requested when it is available.' );
+	}
+
 	/**
 	 * @testdox Should set up the lost connection notice and heartbeat correctly per screen, and never re-enqueue autosave.
 	 * @testWith ["woocommerce_page_wc-orders", "woocommerce_page_wc-orders", "", false, true]
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 a9220c36d20..32f543cd38d 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
@@ -116,6 +116,7 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 			'active simple schedule'   => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2099-12-31 23:59:59', '2000-01-01', '2099-12-31', '2000-01-01 00:00:00', '2099-12-31 23:59:59' ),
 			'future external schedule' => array( ProductType::EXTERNAL, '2098-01-01 00:00:00', '2098-12-31 23:59:59', '2098-02-01', '2098-11-30', '2098-02-01 00:00:00', '2098-11-30 23:59:59' ),
 			'expired simple schedule'  => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2001-01-01 23:59:59', '2000-01-01', '2001-01-01', '2000-01-01 00:00:00', '2001-01-01 23:59:59' ),
+			'valid leap day schedule'  => array( ProductType::SIMPLE, null, null, '2024-02-29', '2024-02-29', '2024-02-29 00:00:00', '2024-02-29 23:59:59' ),
 			'empty external schedule'  => array( ProductType::EXTERNAL, null, null, '', '', null, null ),
 		);
 	}
@@ -196,24 +197,48 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Quick Edit normalizes invalid sale dates like the full product editor.
+	 * @testdox Quick Edit preserves sale dates when submitted values are invalid.
+	 * @dataProvider invalid_sale_date_provider
+	 *
+	 * @param mixed $submitted_date Submitted sale date.
 	 */
-	public function test_quick_edit_matches_full_editor_invalid_date_behavior(): void {
+	public function test_quick_edit_preserves_sale_dates_for_invalid_values( $submitted_date ): void {
 		$product = $this->create_product( ProductType::SIMPLE );
+		$product->set_date_on_sale_from( '2098-01-01 00:00:00' );
+		$product->set_date_on_sale_to( '2098-12-31 23:59:59' );
+		$product->save();

 		$this->quick_edit(
 			$product,
 			array(
-				'_sale_price_dates_from' => 'invalid-date',
-				'_sale_price_dates_to'   => 'invalid-date',
+				'_sale_price_dates_from' => $submitted_date,
+				'_sale_price_dates_to'   => $submitted_date,
 			)
 		);

 		$updated_product = wc_get_product( $product->get_id() );

-		// Like the full editor, an invalid start becomes timestamp 0, which WC_Data treats as empty on reload.
-		$this->assert_date( null, $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
-		$this->assert_date( '1970-01-01 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
+		$this->assert_date( '2098-01-01 00:00:00', $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+		$this->assert_date( '2098-12-31 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
+	}
+
+	/**
+	 * Provides invalid sale dates.
+	 *
+	 * @return array<string, array{mixed}>
+	 */
+	public static function invalid_sale_date_provider(): array {
+		return array(
+			'unparseable string' => array( 'tomorow' ),
+			'non-string value'   => array( array( '2025-02-28' ) ),
+			'invalid leap day'   => array( '2025-02-29' ),
+			'invalid June day'   => array( '2025-06-31' ),
+			'zero month'         => array( '2025-00-30' ),
+			'month without zero' => array( '2025-2-03' ),
+			'time suffix'        => array( '2025-02-03 00:00:00' ),
+			'whitespace only'    => array( ' ' ),
+			'padded date'        => array( ' 2025-02-03 ' ),
+		);
 	}

 	/**