Commit de56eddad25 for woocommerce

commit de56eddad25fd0313de42e2e2e9981756adbd884
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Mon Nov 2 20:28:40 2026 +0700

    Show sale prices only after a scheduled sale starts (#70385)

    * fix: Hide sale prices until the scheduled sale starts

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * chore: Drop stale PHPStan baseline entry for grouped structured data

    Amp-Thread-ID: https://ampcode.com/threads/T-01a18807-47e9-70c0-a19e-39af62eae54e
    Co-authored-by: Amp <amp@ampcode.com>

    * test: Cover sale prices for sales that have not started

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
    Co-authored-by: Amp <amp@ampcode.com>

diff --git a/plugins/woocommerce/changelog/fix-scheduled-sale-price b/plugins/woocommerce/changelog/fix-scheduled-sale-price
new file mode 100644
index 00000000000..96c23c2d68d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-scheduled-sale-price
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show sale prices in the Store API and grouped product structured data only after the scheduled sale starts.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index d22c2130b29..f9c04b9aeab 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -366,6 +366,7 @@ class WC_Structured_Data {
 				$tax_display_mode = get_option( 'woocommerce_tax_display_shop' );
 				$child_ids        = $product->get_children();
 				_prime_post_caches( $child_ids );
+				/** @var WC_Product[] $children */ // phpcs:ignore Generic.Commenting.DocComment.MissingShort
 				$children       = array_filter( array_map( 'wc_get_product', $child_ids ), 'wc_products_array_filter_visible_grouped' );
 				$price_function = TaxDisplayMode::INCLUSIVE === $tax_display_mode ? 'wc_get_price_including_tax' : 'wc_get_price_excluding_tax';

@@ -373,7 +374,7 @@ class WC_Structured_Data {
 					if ( '' !== $child->get_regular_price() ) {
 						$child_prices[] = $price_function( $child, array( 'price' => $child->get_regular_price() ) );
 					}
-					if ( '' !== $child->get_sale_price() ) {
+					if ( '' !== $child->get_sale_price() && $child->is_on_sale() ) {
 						$child_sale_prices[] = $price_function( $child, array( 'price' => $child->get_sale_price() ) );
 					}
 				}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 187ea0065c7..5d2afeb5600 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -15024,18 +15024,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-structured-data.php

-		-
-			message: '#^Cannot call method get_regular_price\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/class-wc-structured-data.php
-
-		-
-			message: '#^Cannot call method get_sale_price\(\) on WC_Product\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 2
-			path: includes/class-wc-structured-data.php
-
 		-
 			message: '#^Method WC_Structured_Data\:\:generate_breadcrumblist_data\(\) has no return type specified\.$#'
 			identifier: missingType.return
@@ -15126,12 +15114,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-structured-data.php

-		-
-			message: '#^Parameter \#1 \$product of callable ''wc_get_price…'' expects WC_Product, WC_Product\|false\|null given\.$#'
-			identifier: argument.type
-			count: 2
-			path: includes/class-wc-structured-data.php
-
 		-
 			message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(WC_Product\|false\|null\)\: bool\)\|null, ''wc_products_array…'' given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
index c8ffbb09af6..85a24f7de13 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
@@ -998,7 +998,7 @@ class ProductSchema extends AbstractSchema {
 			$sale_price    = $product->get_variation_sale_price();
 		} else {
 			$regular_price = $product->get_regular_price();
-			$sale_price    = $product->get_sale_price();
+			$sale_price    = $product->is_on_sale() ? $product->get_sale_price() : '';
 		}

 		$prices['price']         = $this->prepare_money_response( $price_function( $product ), $price_decimals );
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
index 9fdc2a61ba7..7870269fb28 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ShopperListItemSchema.php
@@ -314,7 +314,7 @@ class ShopperListItemSchema extends AbstractSchema {
 	private function get_prices( \WC_Product $product ): array {
 		$decimals      = wc_get_price_decimals();
 		$regular_price = $product->get_regular_price();
-		$sale_price    = $product->get_sale_price();
+		$sale_price    = $product->is_on_sale() ? $product->get_sale_price() : '';
 		$current_price = $product->get_price();

 		return $this->prepare_currency_response(
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
index 6bbdeda69a6..eff85232b7a 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductItemTrait.php
@@ -25,7 +25,7 @@ trait ProductItemTrait {
 			'precision'     => $rounding_precision,
 			'price'         => $this->prepare_money_response( $price_function( $product ), $rounding_precision ),
 			'regular_price' => $this->prepare_money_response( $price_function( $product, array( 'price' => $product->get_regular_price() ) ), $rounding_precision ),
-			'sale_price'    => $this->prepare_money_response( $price_function( $product, array( 'price' => $product->get_sale_price() ) ), $rounding_precision ),
+			'sale_price'    => $this->prepare_money_response( $price_function( $product, array( 'price' => $product->is_on_sale() ? $product->get_sale_price() : '' ) ), $rounding_precision ),
 		);

 		return $prices;
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 5f30e14b41f..67a46b26bb2 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -646,6 +646,37 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( get_woocommerce_currency(), $offer['priceCurrency'] );
 	}

+	/**
+	 * @testdox Grouped product offer ignores child sale prices whose sale has not started.
+	 */
+	public function test_grouped_product_offer_ignores_scheduled_child_sale_price(): void {
+		$product   = WC_Helper_Product::create_grouped_product();
+		$children  = $product->get_children();
+		$active    = wc_get_product( $children[0] );
+		$scheduled = wc_get_product( $children[1] );
+		$active->set_props(
+			array(
+				'regular_price' => '100',
+				'sale_price'    => '70',
+			)
+		);
+		$active->save();
+		$scheduled->set_props(
+			array(
+				'regular_price'     => '100',
+				'sale_price'        => '63.45',
+				'date_on_sale_from' => strtotime( '+1 year' ),
+			)
+		);
+		$scheduled->save();
+
+		$this->structured_data->generate_product_data( wc_get_product( $product->get_id() ) );
+
+		$prices = wp_list_pluck( $this->structured_data->get_data()[0]['offers'][0]['priceSpecification'], 'price' );
+		$this->assertContains( '70.00', $prices );
+		$this->assertNotContains( '63.45', $prices, 'A child sale that has not started must not be advertised.' );
+	}
+
 	/**
 	 * @testdox valueAddedTaxIncluded is present in product structured data when taxes are enabled.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
index a440fa90d34..8ebb68bb26c 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartItems.php
@@ -381,6 +381,27 @@ class CartItems extends ControllerTestCase {
 		$this->assertArrayHasKey( 'catalog_visibility', $data );
 	}

+	/**
+	 * @testdox Cart item prices do not expose a sale price before the scheduled sale starts.
+	 */
+	public function test_cart_item_sale_price_is_hidden_until_scheduled_sale_starts(): void {
+		$product = ( new FixtureData() )->get_simple_product(
+			array(
+				'regular_price'     => 100,
+				'sale_price'        => 63.45,
+				'date_on_sale_from' => strtotime( '+1 year' ),
+			)
+		);
+		wc_empty_cart();
+		$key = wc()->cart->add_to_cart( $product->get_id() );
+
+		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart/items/' . $key ) );
+		$prices   = $response->get_data()['prices'];
+
+		$this->assertSame( '10000', $prices->sale_price );
+		$this->assertSame( $prices->raw_prices['price'], $prices->raw_prices['sale_price'] );
+	}
+
 	/**
 	 * @testdox Cart lines include a null parent item key by default, declared in the schema as a readonly string or null.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
index 530c0e412b8..f8e3d037969 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
@@ -132,6 +132,34 @@ class Products extends ControllerTestCase {
 		wp_delete_attachment( $image_id, true );
 	}

+	/**
+	 * @testdox Sale price is only returned while the sale is active.
+	 */
+	public function test_sale_price_is_hidden_until_scheduled_sale_starts(): void {
+		$fixtures  = new FixtureData();
+		$scheduled = $fixtures->get_simple_product(
+			array(
+				'regular_price'     => 100,
+				'sale_price'        => 63.45,
+				'date_on_sale_from' => strtotime( '+1 year' ),
+				'date_on_sale_to'   => strtotime( '+1 year +3 days' ),
+			)
+		);
+		$active    = $fixtures->get_simple_product(
+			array(
+				'regular_price' => 100,
+				'sale_price'    => 70,
+			)
+		);
+
+		$scheduled_prices = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $scheduled->get_id() ) )->get_data()['prices'];
+		$active_prices    = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $active->get_id() ) )->get_data()['prices'];
+
+		$this->assertSame( '10000', $scheduled_prices->price );
+		$this->assertSame( '10000', $scheduled_prices->sale_price, 'A sale that has not started must not expose its sale price.' );
+		$this->assertSame( '7000', $active_prices->sale_price, 'An active sale returns its sale price.' );
+	}
+
 	/**
 	 * Test get grouped product.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
index 5013794ee11..ba3797b31fd 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ShopperLists.php
@@ -332,6 +332,26 @@ class ShopperLists extends ControllerTestCase {
 		$this->assertCount( 1, $response->get_data() );
 	}

+	/**
+	 * @testdox Saved items do not expose a sale price before the scheduled sale starts.
+	 */
+	public function test_saved_item_sale_price_is_hidden_until_scheduled_sale_starts() {
+		wp_set_current_user( $this->customer_id );
+		$this->product->set_props(
+			array(
+				'sale_price'        => 5,
+				'date_on_sale_from' => strtotime( '+1 year' ),
+			)
+		);
+		$this->product->save();
+
+		$this->dispatch( 'POST', '/wc/store/v1/shopper-lists/saved-for-later/items', array( 'product_id' => $this->product->get_id() ) );
+		$items = $this->dispatch( 'GET', '/wc/store/v1/shopper-lists/saved-for-later/items' )->get_data();
+
+		$this->assertCount( 1, $items );
+		$this->assertSame( '', $items[0]['prices']->sale_price );
+	}
+
 	/**
 	 * Test that DELETE removes the item and returns 204 No Content.
 	 */