Commit 695243f098d for woocommerce

commit 695243f098d859f81d673e57e21ab588df904e2f
Author: Bero <1199991+bgrgicak@users.noreply.github.com>
Date:   Thu Nov 5 11:50:26 2026 +0100

    Add sale dates to product schema offers (#70586)

    * Add sale dates to product schema offers

    * Add changelog entry for product sale dates

    * Fix sale end dates for the store timezone

    * Fix sale date test on PHP 7.4

    * Fix sale date tests on 32-bit PHP

    * Fix localized sale dates in product schema

diff --git a/plugins/woocommerce/changelog/add-product-schema-sale-dates b/plugins/woocommerce/changelog/add-product-schema-sale-dates
new file mode 100644
index 00000000000..05106f8696b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-product-schema-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Include active sale dates in product structured data so shopping agents can see when sale prices apply.
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index 713dbb94942..fe064584c90 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -460,7 +460,8 @@ class WC_Structured_Data {
 						? wc_get_price_including_tax( $offer_source, array( 'price' => $offer_source->get_sale_price() ) )
 						: wc_get_price_excluding_tax( $offer_source, array( 'price' => $offer_source->get_sale_price() ) );
 					if ( $offer_source->get_date_on_sale_to() ) {
-						$sale_price_valid_until = gmdate( 'Y-m-d', $offer_source->get_date_on_sale_to()->getTimestamp() );
+						$sale_date_to           = clone $offer_source->get_date_on_sale_to();
+						$sale_price_valid_until = (string) $sale_date_to->setTimezone( wp_timezone() )->format( 'Y-m-d' );
 					}

 					// We add the sale price to the top of the array so it's the first offer.
@@ -506,6 +507,17 @@ class WC_Structured_Data {
 				$markup_offer['priceCurrency'] = $currency;
 			}

+			if ( ( ! $product->is_type( array( ProductType::VARIABLE, ProductType::GROUPED ) ) || $selected_variation ) && $offer_source->is_on_sale() ) {
+				$date_on_sale_from = $offer_source->get_date_on_sale_from();
+				$date_on_sale_to   = $offer_source->get_date_on_sale_to();
+				if ( $date_on_sale_from ) {
+					$markup_offer['validFrom'] = ( clone $date_on_sale_from )->setTimezone( wp_timezone() )->format( DATE_ATOM );
+				}
+				if ( $date_on_sale_to ) {
+					$markup_offer['validThrough'] = ( clone $date_on_sale_to )->setTimezone( wp_timezone() )->format( DATE_ATOM );
+				}
+			}
+
 			$markup['offers'] = array( apply_filters( 'woocommerce_structured_data_product_offer', $markup_offer, $product ) );
 		}

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 adce45e8099..e3f79598c75 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
@@ -684,15 +684,18 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * Test on-sale simple product offer reports the sale price at the offer top level.
+	 * @testdox An active sale offer includes the sale price and dates with daylight saving offsets.
 	 *
 	 * @return void
 	 */
 	public function test_simple_product_offer_on_sale_uses_sale_price_at_offer_level(): void {
+		update_option( 'timezone_string', 'America/New_York' );
 		$product = WC_Helper_Product::create_simple_product();
 		$product->set_regular_price( '100' );
 		$product->set_sale_price( '70' );
 		$product->set_price( '70' );
+		$product->set_date_on_sale_from( strtotime( '2020-01-01 12:00:00 UTC' ) );
+		$product->set_date_on_sale_to( strtotime( '2037-06-01 12:00:00 UTC' ) );
 		$product->save();

 		$this->structured_data->generate_product_data( $product );
@@ -704,6 +707,40 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 		$this->assertEquals( '70.00', $offer['price'] );
 		$this->assertEquals( '70.00', $offer['priceSpecification'][0]['price'] );
 		$this->assertEquals( get_woocommerce_currency(), $offer['priceCurrency'] );
+		$this->assertSame( '2020-01-01T07:00:00-05:00', $offer['validFrom'] );
+		$this->assertSame( '2037-06-01T08:00:00-04:00', $offer['validThrough'] );
+	}
+
+	/**
+	 * @testdox Sale dates are emitted only when configured and the sale is active.
+	 * @testWith ["active"]
+	 *           ["future"]
+	 *           ["expired"]
+	 *           ["unscheduled"]
+	 *
+	 * @param string $schedule Sale schedule to check.
+	 */
+	public function test_sale_offer_dates_with_manual_offset( string $schedule ): void {
+		update_option( 'timezone_string', '' );
+		update_option( 'gmt_offset', 5.5 );
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( '100' );
+		$product->set_sale_price( '70' );
+		$product->set_price( '70' );
+		if ( 'unscheduled' !== $schedule ) {
+			$product->set_date_on_sale_from( strtotime( 'future' === $schedule ? '+1 day' : '2020-01-01 12:00:00 UTC' ) );
+			$product->set_date_on_sale_to( strtotime( 'expired' === $schedule ? '-1 day' : '2037-06-01 12:00:00 UTC' ) );
+		}
+
+		$this->structured_data->generate_product_data( $product );
+		$offer = $this->structured_data->get_data()[0]['offers'][0];
+		if ( 'active' === $schedule ) {
+			$this->assertSame( '2020-01-01T17:30:00+05:30', $offer['validFrom'] );
+			$this->assertSame( '2037-06-01T17:30:00+05:30', $offer['validThrough'] );
+		} else {
+			$this->assertArrayNotHasKey( 'validFrom', $offer );
+			$this->assertArrayNotHasKey( 'validThrough', $offer );
+		}
 	}

 	/**
@@ -902,7 +939,18 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 	 * @return void
 	 */
 	public function test_variable_product_with_selected_variation_uses_single_offer(): void {
+		update_option( 'timezone_string', 'UTC' );
 		$product = WC_Helper_Product::create_variation_product();
+		foreach ( $product->get_children() as $variation_id ) {
+			$variation = wc_get_product( $variation_id );
+			if ( 'DUMMY SKU VARIABLE HUGE RED 0' === $variation->get_sku() ) {
+				$variation->set_sale_price( '12' );
+				$variation->set_date_on_sale_from( strtotime( '2020-01-01 12:00:00 UTC' ) );
+				$variation->set_date_on_sale_to( strtotime( '2037-06-01 12:00:00 UTC' ) );
+				$variation->save();
+				break;
+			}
+		}
 		WC_Product_Variable::sync( $product->get_id() );
 		$product = wc_get_product( $product->get_id() );

@@ -917,7 +965,9 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 			$offer = $data[0]['offers'][0];

 			$this->assertEquals( 'Offer', $offer['@type'] );
-			$this->assertEquals( '16.00', $offer['price'] );
+			$this->assertEquals( '12.00', $offer['price'] );
+			$this->assertSame( '2020-01-01T12:00:00+00:00', $offer['validFrom'] );
+			$this->assertSame( '2037-06-01T12:00:00+00:00', $offer['validThrough'] );
 			$this->assertArrayNotHasKey( 'lowPrice', $offer );
 			$this->assertArrayNotHasKey( 'highPrice', $offer );
 			$this->assertEquals( get_woocommerce_currency(), $offer['priceCurrency'] );
@@ -967,7 +1017,12 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 	 * @return void
 	 */
 	public function test_variable_product_without_full_selection_uses_aggregate_offer(): void {
-		$product = WC_Helper_Product::create_variation_product();
+		$product   = WC_Helper_Product::create_variation_product();
+		$variation = wc_get_product( $product->get_children()[0] );
+		$variation->set_sale_price( '8' );
+		$variation->set_date_on_sale_from( strtotime( '-1 day' ) );
+		$variation->set_date_on_sale_to( strtotime( '+1 day' ) );
+		$variation->save();
 		WC_Product_Variable::sync( $product->get_id() );
 		$product = wc_get_product( $product->get_id() );

@@ -982,6 +1037,8 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
 			$this->assertEquals( 'AggregateOffer', $offer['@type'] );
 			$this->assertArrayHasKey( 'lowPrice', $offer );
 			$this->assertArrayHasKey( 'highPrice', $offer );
+			$this->assertArrayNotHasKey( 'validFrom', $offer );
+			$this->assertArrayNotHasKey( 'validThrough', $offer );
 			$this->assertArrayNotHasKey( 'inProductGroupWithID', $data[0] );
 		} finally {
 			unset( $_GET['attribute_pa_size'] );