Commit ccb63ed78df for woocommerce

commit ccb63ed78dfba6b67a696fe28c36d325e48cfdb3
Author: Tung Du <dinhtungdu@gmail.com>
Date:   Tue Oct 6 16:14:20 2026 +0700

    Add discount amount and percentage options to sale badges (#69180)

diff --git a/docs/block-development/reference/block-references.md b/docs/block-development/reference/block-references.md
index ee3f2438804..669d7fb4bfa 100644
--- a/docs/block-development/reference/block-references.md
+++ b/docs/block-development/reference/block-references.md
@@ -1191,7 +1191,7 @@ Displays an on-sale badge if the product is on-sale.
 - **Category:** woocommerce-product-elements
 - **Ancestor:** woocommerce/single-product, woocommerce/product-template, core/post-template, woocommerce/product-gallery
 - **Supports:** align, color (background, gradients, text, ~~link~~), email, interactivity (clientNavigation), spacing (margin), typography (fontSize, lineHeight), ~~html~~
-- **Attributes:** productId
+- **Attributes:** badgeContent, prefix, productId, saleText, suffix

 ## Product SKU - woocommerce/product-sku

diff --git a/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off b/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off
new file mode 100644
index 00000000000..e7cb0e4af74
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7155-sale-badge-percent-off
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Show discount amounts or percentages on product sale badges, including the largest saving for variable products.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
index dd5951359ee..56c8ea77112 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.json
@@ -4,7 +4,11 @@
 	"description": "Displays an on-sale badge if the product is on-sale.",
 	"category": "woocommerce-product-elements",
 	"attributes": {
-		"productId": { "type": "number", "default": 0 }
+		"productId": { "type": "number", "default": 0 },
+		"badgeContent": { "type": "string" },
+		"saleText": { "type": "string" },
+		"prefix": { "type": "string" },
+		"suffix": { "type": "string" }
 	},
 	"supports": {
 		"interactivity": {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
index d95739c1fbf..76e47991ab2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/block.tsx
@@ -1,7 +1,11 @@
 /**
  * External dependencies
  */
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+	formatPrice,
+	getCurrencyFromPriceResponse,
+} from '@woocommerce/price-format';
 import clsx from 'clsx';
 import { Label } from '@woocommerce/blocks-components';
 import {
@@ -10,7 +14,7 @@ import {
 } from '@woocommerce/shared-context';
 import { useStyleProps } from '@woocommerce/base-hooks';
 import { withProductDataContext } from '@woocommerce/shared-hocs';
-import type { HTMLAttributes } from 'react';
+import type { HTMLAttributes, ReactElement } from 'react';

 /**
  * Internal dependencies
@@ -24,7 +28,7 @@ type Props = BlockAttributes &
 		isDescendentOfSingleProductTemplate: boolean;
 	};

-export const Block = ( props: Props ): JSX.Element | null => {
+export const Block = ( props: Props ): ReactElement | null => {
 	const { className, align, isDescendentOfSingleProductTemplate } = props;
 	const styleProps = useStyleProps( props );
 	const { parentClassName } = useInnerBlockLayoutContext();
@@ -41,6 +45,45 @@ export const Block = ( props: Props ): JSX.Element | null => {
 		return null;
 	}

+	const isNumeric =
+		props.badgeContent === 'amount' || props.badgeContent === 'percentage';
+
+	let label = props.saleText || __( 'Sale', 'woocommerce' );
+	if ( isNumeric && product.type !== 'grouped' ) {
+		const prices = 'prices' in product ? product.prices : undefined;
+		const regular = Number( prices?.regular_price );
+		const price = Number( prices?.price );
+		if ( regular > 0 && price < regular ) {
+			const discount = regular - price;
+			const percentage = Math.round( ( discount / regular ) * 100 );
+			if ( props.badgeContent === 'percentage' && percentage === 0 ) {
+				return null;
+			}
+			const value =
+				props.badgeContent === 'percentage'
+					? sprintf(
+							/* translators: %s: discount percentage. %% is the percent sign. */
+							__( '%s%%', 'woocommerce' ),
+							percentage
+					  )
+					: formatPrice(
+							discount,
+							getCurrencyFromPriceResponse( prices )
+					  );
+			// Parent prices are independent minima, so the editor preview may differ from the largest variation discount.
+			label =
+				product.type === 'variable'
+					? sprintf(
+							/* translators: %s: approximate discount for a variable product in the editor. */
+							__( 'Up to %s', 'woocommerce' ),
+							value
+					  )
+					: `${ props.prefix ?? '' }${ value }${
+							props.suffix ?? ''
+					  }`;
+		}
+	}
+
 	const alignClass =
 		typeof align === 'string'
 			? `wc-block-components-product-sale-badge--align-${ align }`
@@ -60,8 +103,12 @@ export const Block = ( props: Props ): JSX.Element | null => {
 			style={ styleProps.style }
 		>
 			<Label
-				label={ __( 'Sale', 'woocommerce' ) }
-				screenReaderLabel={ __( 'Product on sale', 'woocommerce' ) }
+				label={ label }
+				screenReaderLabel={ sprintf(
+					/* translators: %s: sale badge text. */
+					__( 'Product on sale: %s', 'woocommerce' ),
+					label
+				) }
 			/>
 		</div>
 	);
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
index c5239b6d537..c36a0d0c685 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/edit.tsx
@@ -1,8 +1,11 @@
 /**
  * External dependencies
  */
-import { useBlockProps } from '@wordpress/block-editor';
+import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
+import { PanelBody, SelectControl, TextControl } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
 import type { BlockEditProps } from '@wordpress/blocks';
+import type { ReactElement } from 'react';
 import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';

 /**
@@ -14,8 +17,9 @@ import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descend

 const Edit = ( {
 	attributes,
+	setAttributes,
 	context,
-}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
+}: BlockEditProps< BlockAttributes > & { context: Context } ): ReactElement => {
 	const blockProps = useBlockProps();

 	// Remove the `style` prop from the block props to avoid passing it to the wrapper div.
@@ -29,14 +33,93 @@ const Edit = ( {
 	};

 	return (
-		<div { ...wrapperProps }>
-			<Block
-				{ ...blockAttrs }
-				isDescendentOfSingleProductTemplate={
-					isDescendentOfSingleProductTemplate
-				}
-			/>
-		</div>
+		<>
+			<InspectorControls>
+				<PanelBody title={ __( 'Badge content', 'woocommerce' ) }>
+					<SelectControl
+						__next40pxDefaultSize
+						__nextHasNoMarginBottom
+						label={ __( 'Show', 'woocommerce' ) }
+						value={ attributes.badgeContent || 'text' }
+						onChange={ ( badgeContent ) => {
+							if (
+								badgeContent === 'text' ||
+								badgeContent === 'amount' ||
+								badgeContent === 'percentage'
+							) {
+								setAttributes( { badgeContent } );
+							}
+						} }
+						options={ [
+							{
+								label: __( 'Text', 'woocommerce' ),
+								value: 'text',
+							},
+							{
+								label: __( 'Amount', 'woocommerce' ),
+								value: 'amount',
+							},
+							{
+								label: __( 'Percentage', 'woocommerce' ),
+								value: 'percentage',
+							},
+						] }
+					/>
+					{ ! attributes.badgeContent ||
+					attributes.badgeContent === 'text' ? (
+						<TextControl
+							__next40pxDefaultSize
+							__nextHasNoMarginBottom
+							label={ __( 'Text', 'woocommerce' ) }
+							help={ sprintf(
+								/* translators: %s: default sale badge text. */
+								__(
+									'If left empty, the badge will display ‘%s’.',
+									'woocommerce'
+								),
+								__( 'Sale', 'woocommerce' )
+							) }
+							value={
+								attributes.saleText ??
+								__( 'Sale', 'woocommerce' )
+							}
+							onChange={ ( saleText ) =>
+								setAttributes( { saleText } )
+							}
+						/>
+					) : (
+						<>
+							<TextControl
+								__next40pxDefaultSize
+								__nextHasNoMarginBottom
+								label={ __( 'Prefix', 'woocommerce' ) }
+								value={ attributes.prefix ?? '' }
+								onChange={ ( prefix ) =>
+									setAttributes( { prefix } )
+								}
+							/>
+							<TextControl
+								__next40pxDefaultSize
+								__nextHasNoMarginBottom
+								label={ __( 'Suffix', 'woocommerce' ) }
+								value={ attributes.suffix ?? '' }
+								onChange={ ( suffix ) =>
+									setAttributes( { suffix } )
+								}
+							/>
+						</>
+					) }
+				</PanelBody>
+			</InspectorControls>
+			<div { ...wrapperProps }>
+				<Block
+					{ ...blockAttrs }
+					isDescendentOfSingleProductTemplate={
+						isDescendentOfSingleProductTemplate
+					}
+				/>
+			</div>
+		</>
 	);
 };

diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx
new file mode 100644
index 00000000000..a5a41de6060
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/test/block.test.tsx
@@ -0,0 +1,181 @@
+import { render, screen } from '@testing-library/react';
+import { getLocaleData, resetLocaleData, setLocaleData } from '@wordpress/i18n';
+
+import { Block } from '../block';
+
+const product = {
+	id: 1,
+	on_sale: true,
+	type: 'variable',
+	prices: {
+		regular_price: '1000',
+		price: '900',
+		currency_code: 'USD',
+		currency_symbol: '$',
+		currency_minor_unit: 2,
+		currency_decimal_separator: '.',
+		currency_thousand_separator: ',',
+		currency_prefix: '$',
+		currency_suffix: '',
+	},
+};
+
+jest.mock( '@woocommerce/shared-context', () => ( {
+	useInnerBlockLayoutContext: () => ( { parentClassName: '' } ),
+	useProductDataContext: () => ( { product } ),
+} ) );
+jest.mock( '@woocommerce/shared-hocs', () => ( {
+	withProductDataContext: ( Component: unknown ) => Component,
+} ) );
+jest.mock( '@woocommerce/base-hooks', () => ( {
+	useStyleProps: () => ( { className: '', style: {} } ),
+} ) );
+
+beforeEach( () => {
+	product.type = 'variable';
+	product.prices.regular_price = '1000';
+	product.prices.price = '900';
+} );
+
+it.each( [
+	[ '', 'Sale' ],
+	[ '0', '0' ],
+] )(
+	'uses the text fallback only for empty custom text (%s)',
+	( saleText, expected ) => {
+		render(
+			<Block
+				productId={ 1 }
+				align={ false }
+				isDescendentOfSingleProductTemplate={ false }
+				badgeContent="text"
+				saleText={ saleText }
+			/>
+		);
+		expect( screen.getByText( expected ) ).toBeInTheDocument();
+	}
+);
+
+it.each< [ string, string, string | null ] >( [
+	[ 'simple', '99600', null ],
+	[ 'simple', '99500', '1%' ],
+] )(
+	'rounds the %s percentage preview at price %s',
+	( type, price, expected ) => {
+		product.type = type;
+		product.prices.regular_price = '100000';
+		product.prices.price = price;
+		const { container } = render(
+			<Block
+				productId={ 1 }
+				align={ false }
+				isDescendentOfSingleProductTemplate={ false }
+				badgeContent="percentage"
+			/>
+		);
+		expect( container.textContent ).toBe(
+			expected === null
+				? ''
+				: `${ expected }Product on sale: ${ expected }`
+		);
+	}
+);
+
+it( 'keeps amount badges for discounts whose percentage rounds to zero', () => {
+	product.prices.regular_price = '100000';
+	product.prices.price = '99600';
+	render(
+		<Block
+			productId={ 1 }
+			align={ false }
+			isDescendentOfSingleProductTemplate={ false }
+			badgeContent="amount"
+		/>
+	);
+	expect( screen.getByText( 'Up to $4.00' ) ).toBeInTheDocument();
+} );
+
+it( 'previews a variable discount using parent prices without rendering on the server', () => {
+	product.prices.price = '900';
+	const { rerender } = render(
+		<Block
+			productId={ 1 }
+			align={ false }
+			isDescendentOfSingleProductTemplate={ false }
+			badgeContent="percentage"
+		/>
+	);
+	expect( screen.getByText( 'Up to 10%' ) ).toBeInTheDocument();
+
+	product.prices.price = '1000';
+	rerender(
+		<Block
+			productId={ 1 }
+			align={ false }
+			isDescendentOfSingleProductTemplate={ false }
+			badgeContent="percentage"
+		/>
+	);
+	expect( screen.getByText( 'Sale' ) ).toBeInTheDocument();
+} );
+
+it( 'preserves currency units without custom affixes when using Up to', () => {
+	product.prices.price = '900';
+	render(
+		<Block
+			productId={ 1 }
+			align={ false }
+			isDescendentOfSingleProductTemplate={ false }
+			badgeContent="amount"
+			prefix="Save "
+			suffix=" off"
+		/>
+	);
+	expect( screen.getByText( 'Up to $1.00' ) ).toBeInTheDocument();
+} );
+
+it.each( [
+	[ 'simple', 'Save 10% off' ],
+	[ 'variable', 'Up to 10%' ],
+] )(
+	'formats the %s percentage label with custom affixes',
+	( type, expected ) => {
+		product.type = type;
+		render(
+			<Block
+				productId={ 1 }
+				align={ false }
+				isDescendentOfSingleProductTemplate={ false }
+				badgeContent="percentage"
+				prefix="Save "
+				suffix=" off"
+			/>
+		);
+		expect( screen.getByText( expected ) ).toBeInTheDocument();
+		expect(
+			screen.getByText( `Product on sale: ${ expected }` )
+		).toBeInTheDocument();
+	}
+);
+
+it( 'uses localized spacing before the percent sign', () => {
+	const localeData = getLocaleData( 'woocommerce' );
+	setLocaleData( { '%s%%': [ '%s\u00a0%%' ] }, 'woocommerce' );
+	try {
+		render(
+			<Block
+				productId={ 1 }
+				align={ false }
+				isDescendentOfSingleProductTemplate={ false }
+				badgeContent="percentage"
+			/>
+		);
+		expect(
+			screen.getByText( 'Up to 10\u00a0%', {
+				normalizer: ( text ) => text,
+			} )
+		).toBeInTheDocument();
+	} finally {
+		resetLocaleData( localeData, 'woocommerce' );
+	}
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
index e64df6f3d4f..e9c5b1c9834 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-elements-blocks/sale-badge/types.ts
@@ -1,3 +1,7 @@
 export interface BlockAttributes {
 	productId: number;
+	badgeContent?: 'text' | 'amount' | 'percentage';
+	saleText?: string;
+	prefix?: string;
+	suffix?: string;
 }
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
index cee9a9c242f..ddc88d90710 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -67,6 +67,60 @@ class ProductSaleBadge extends AbstractBlock {

 		$align = isset( $attributes['align'] ) ? $attributes['align'] : '';

+		$sale_text     = $attributes['saleText'] ?? '';
+		$sale_text     = '' === $sale_text ? __( 'Sale', 'woocommerce' ) : $sale_text;
+		$badge_content = $attributes['badgeContent'] ?? 'text';
+
+		if ( in_array( $badge_content, array( 'amount', 'percentage' ), true ) ) {
+			$amount                  = 0;
+			$percentage              = 0;
+			$has_different_discounts = false;
+
+			if ( $product instanceof \WC_Product_Variable ) {
+				$prices    = $product->get_variation_prices( 'amount' === $badge_content );
+				$discounts = array();
+				foreach ( $prices['price'] as $variation_id => $price ) {
+					$regular     = (float) $prices['regular_price'][ $variation_id ];
+					$discount    = max( 0, $regular - (float) $price );
+					$discounts[] = 'amount' === $badge_content ? $discount : ( $regular > 0 ? $discount / $regular : 0 );
+					if ( $regular <= 0 || (float) $price >= $regular ) {
+						continue;
+					}
+					$amount     = max( $amount, $regular - (float) $price );
+					$percentage = max( $percentage, ( $regular - (float) $price ) / $regular );
+				}
+				$has_different_discounts = count( array_unique( $discounts ) ) > 1;
+			} else {
+				$regular = (float) $product->get_regular_price();
+				$price   = (float) $product->get_price();
+				if ( 'amount' === $badge_content ) {
+					$regular = wc_get_price_to_display( $product, array( 'price' => $regular ) );
+					$price   = wc_get_price_to_display( $product, array( 'price' => $price ) );
+				}
+				if ( $regular > 0 && $price < $regular ) {
+					$amount     = $regular - $price;
+					$percentage = $amount / $regular;
+				}
+			}
+
+			if ( $amount > 0 ) {
+				if ( 'percentage' === $badge_content && round( $percentage * 100 ) < 1 ) {
+					return '';
+				}
+				$value = 'percentage' === $badge_content
+					/* translators: %s: discount percentage. %% is the percent sign. */
+					? sprintf( __( '%s%%', 'woocommerce' ), round( $percentage * 100 ) )
+					: html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+
+				if ( $has_different_discounts ) {
+					/* translators: %s: largest discount across variations. */
+					$sale_text = sprintf( __( 'Up to %s', 'woocommerce' ), $value );
+				} else {
+					$sale_text = ( $attributes['prefix'] ?? '' ) . $value . ( $attributes['suffix'] ?? '' );
+				}
+			}
+		}
+
 		/**
 		 * Filters the product sale badge text.
 		 *
@@ -77,14 +131,15 @@ class ProductSaleBadge extends AbstractBlock {
 		 * @param WC_Product $product The product object.
 		 * @return string The filtered sale badge text.
 		 */
-		$sale_text = apply_filters( 'woocommerce_sale_badge_text', __( 'Sale', 'woocommerce' ), $product );
+		$sale_text = apply_filters( 'woocommerce_sale_badge_text', $sale_text, $product );

 		$output  = '<div class="wp-block-woocommerce-product-sale-badge ' . esc_attr( $classname ) . '">';
 		$output .= sprintf( '<div class="wc-block-components-product-sale-badge %1$s wc-block-components-product-sale-badge--align-%2$s" style="%3$s">', esc_attr( $classes_and_styles['classes'] ), esc_attr( $align ), esc_attr( $classes_and_styles['styles'] ) );
 		$output .= '<span class="wc-block-components-product-sale-badge__text" aria-hidden="true">' . esc_html( $sale_text ) . '</span>';
-		$output .= '<span class="screen-reader-text">'
-						. __( 'Product on sale', 'woocommerce' )
-					. '</span>';
+		/* translators: %s: sale badge text. */
+		$screen_reader_text = sprintf( __( 'Product on sale: %s', 'woocommerce' ), $sale_text );
+
+		$output .= '<span class="screen-reader-text">' . esc_html( $screen_reader_text ) . '</span>';
 		$output .= '</div></div>';

 		return $output;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
index b4c58ab1276..988332dc616 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductSaleBadge.php
@@ -85,6 +85,215 @@ class ProductSaleBadge extends \WP_UnitTestCase {
 		}
 	}

+	/**
+	 * @testdox Product Sale Badge displays the configured content for a simple sale product.
+	 * @dataProvider provider_product_sale_badge_content
+	 * @param array  $attributes Badge attributes.
+	 * @param string $expected Expected badge text.
+	 */
+	public function test_product_sale_badge_content( array $attributes, string $expected ): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( '20' );
+		$product->set_sale_price( '15' );
+		$product->save();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( (object) $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+
+		$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+		$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+	}
+
+	/**
+	 * Sale badge content options.
+	 *
+	 * @return array<string, array{array, string}>
+	 */
+	public function provider_product_sale_badge_content(): array {
+		return array(
+			'empty'      => array( array( 'saleText' => '' ), 'Sale' ),
+			'zero text'  => array( array( 'saleText' => '0' ), '0' ),
+			'custom'     => array(
+				array( 'saleText' => 'Special offer' ),
+				'Special offer',
+			),
+			'percentage' => array(
+				array(
+					'badgeContent' => 'percentage',
+					'prefix'       => 'Save ',
+					'suffix'       => ' off',
+				),
+				'Save 25% off',
+			),
+			'amount'     => array(
+				array(
+					'badgeContent' => 'amount',
+					'prefix'       => 'Save ',
+				),
+				'Save $5.00',
+			),
+		);
+	}
+
+	/**
+	 * @testdox Percentage badges omit zero-rounded discounts while preserving the rounding boundary.
+	 * @dataProvider provider_percentage_badge_rounding
+	 * @param string $type Product type.
+	 * @param string $sale Sale price.
+	 * @param string $mode Badge content mode.
+	 * @param string $expected Expected badge text, or empty for an omitted badge.
+	 */
+	public function test_percentage_badge_rounding( string $type, string $sale, string $mode, string $expected ): void {
+		$product = 'variable' === $type ? \WC_Helper_Product::create_variation_product() : \WC_Helper_Product::create_simple_product();
+		$priced  = 'variable' === $type ? wc_get_product( $product->get_children()[0] ) : $product;
+		$priced->set_regular_price( '1000' );
+		$priced->set_sale_price( $sale );
+		$priced->save();
+
+		$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"' . $mode . '"} /--><!-- /wp:woocommerce/single-product -->' );
+
+		if ( '' === $expected ) {
+			$this->assertStringNotContainsString( 'wc-block-components-product-sale-badge', $markup, 'Zero-rounded percentage badges should be omitted.' );
+		} else {
+			$this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+		}
+	}
+
+	/**
+	 * Percentage rounding and unchanged amount-mode cases.
+	 *
+	 * @return array
+	 */
+	public function provider_percentage_badge_rounding(): array {
+		return array(
+			'simple zero'     => array( 'simple', '996', 'percentage', '' ),
+			'simple half'     => array( 'simple', '995', 'percentage', '1%' ),
+			'variable zero'   => array( 'variable', '996', 'percentage', '' ),
+			'variable half'   => array( 'variable', '995', 'percentage', 'Up to 1%' ),
+			'simple amount'   => array( 'simple', '996', 'amount', '$4.00' ),
+			'variable amount' => array( 'variable', '996', 'amount', 'Up to $4.00' ),
+		);
+	}
+
+	/**
+	 * @testdox Variable product badge uses each variation's own regular price for the largest discount.
+	 */
+	public function test_variable_product_sale_badge_displays_maximum_discount(): void {
+		$product    = \WC_Helper_Product::create_variation_product();
+		$variations = $product->get_children();
+
+		$first = wc_get_product( $variations[0] );
+		$first->set_sale_price( '5' );
+		$first->save();
+
+		$second = wc_get_product( $variations[1] );
+		$second->set_sale_price( '9' );
+		$second->save();
+
+		foreach (
+			array(
+				'percentage' => 'Up to 50%',
+				'amount'     => 'Up to $6.00',
+			) as $mode => $expected
+		) {
+			$attributes = array(
+				'badgeContent' => $mode,
+				'prefix'       => 'Save ',
+				'suffix'       => ' off',
+			);
+			$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+			$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+			$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+		}
+	}
+
+	/**
+	 * @testdox Uniform variation discounts retain affixes only in the matching content mode.
+	 * @dataProvider provider_uniform_variation_discounts
+	 * @param string $sale Second variation sale price.
+	 * @param string $mode Badge content mode.
+	 * @param string $expected Expected badge text.
+	 */
+	public function test_uniform_variation_discounts( string $sale, string $mode, string $expected ): void {
+		$product = \WC_Helper_Product::create_variation_product();
+		foreach ( $product->get_children() as $index => $id ) {
+			$variation = wc_get_product( $id );
+			$variation->set_regular_price( 0 === $index ? '10' : '20' );
+			$variation->set_sale_price( 0 === $index ? '9' : $sale );
+			$variation->save();
+		}
+		$attributes = array(
+			'badgeContent' => $mode,
+			'prefix'       => 'Save ',
+			'suffix'       => ' off',
+		);
+		$markup     = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-sale-badge ' . wp_json_encode( $attributes ) . ' /--><!-- /wp:woocommerce/single-product -->' );
+		$this->assertStringContainsString( 'aria-hidden="true">' . $expected . '</span>', $markup );
+		$this->assertStringContainsString( 'screen-reader-text">Product on sale: ' . $expected . '</span>', $markup );
+	}
+
+	/**
+	 * Uniform percentages, uniform amounts, and undiscounted variations.
+	 *
+	 * @return array
+	 */
+	public function provider_uniform_variation_discounts(): array {
+		return array(
+			'equal percentage'       => array( '18', 'percentage', 'Save 10% off' ),
+			'different amount'       => array( '18', 'amount', 'Up to $2.00' ),
+			'equal amount'           => array( '19', 'amount', 'Save $1.00 off' ),
+			'different percentage'   => array( '19', 'percentage', 'Up to 10%' ),
+			'undiscounted variation' => array( '20', 'percentage', 'Up to 10%' ),
+		);
+	}
+
+	/**
+	 * @testdox Discount amount reflects prices shown with tax on the storefront.
+	 */
+	public function test_discount_amount_includes_displayed_tax(): void {
+		add_filter( 'wc_tax_enabled', '__return_true' );
+		update_option( 'woocommerce_prices_include_tax', 'no' );
+		update_option( 'woocommerce_tax_display_shop', 'incl' );
+		$tax_id = \WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => '',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '20.0000',
+				'tax_rate_name'     => 'VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_compound' => 0,
+				'tax_rate_shipping' => 1,
+				'tax_rate_order'    => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+
+		try {
+			$simple = \WC_Helper_Product::create_simple_product();
+			$simple->set_regular_price( '100' );
+			$simple->set_sale_price( '80' );
+			$simple->save();
+
+			$variable  = \WC_Helper_Product::create_variation_product();
+			$variation = wc_get_product( $variable->get_children()[0] );
+			$variation->set_regular_price( '100' );
+			$variation->set_sale_price( '80' );
+			$variation->save();
+
+			foreach (
+				array(
+					$simple->get_id()   => '$24.00',
+					$variable->get_id() => 'Up to $24.00',
+				) as $product_id => $expected
+			) {
+				$markup = do_blocks( '<!-- wp:woocommerce/single-product {"productId":' . $product_id . '} --><!-- wp:woocommerce/product-sale-badge {"badgeContent":"amount"} /--><!-- /wp:woocommerce/single-product -->' );
+				$this->assertStringContainsString( 'wc-block-components-product-sale-badge__text" aria-hidden="true">' . $expected . '</span>', $markup );
+			}
+		} finally {
+			\WC_Tax::_delete_tax_rate( $tax_id );
+			remove_filter( 'wc_tax_enabled', '__return_true' );
+		}
+	}
+
 	/**
 	 * Alignment values for the Sale Badge.
 	 *