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