Commit 4f771926265 for woocommerce

commit 4f771926265ffab8724935c19609888c67b1a20a
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Tue Oct 6 12:36:00 2026 +0300

    Fix localized negative price formatting (#66703)

    * Fix localized negative price formatting

    * Add changelog entry for negative price formatting

    * Improve signed price regression tests

    * Preserve minus signs for zero-value price displays

    * Add explicit negative price display intent

    * Update changelog for negative price formatting

    * Preserve default wc_price argument shape

    * Pass the displayed sign to the wc_price filter

    Call sites that used to render discounts and refunds as '-' . wc_price()
    now pass is_negative to wc_price(), which moved the minus sign inside the
    markup handed to the wc_price filter. Plugins that rebuild that markup
    from the unformatted price (currency switchers) would drop the sign,
    because the unformatted price stayed positive.

    Derive the unformatted price from the displayed sign instead, so filter
    callbacks that rebuild the output reproduce the minus. Default call
    paths are unchanged: the value only flips when is_negative overrides
    the natural sign of the input.

    * Pass signed amounts to wc_price instead of an is_negative argument

    Discount and refund displays in the cart totals, the order editor and
    the order preview prepended a literal '-' outside the wc_price() markup,
    so extensions customizing negative prices through the wc_price filters
    never saw the sign. The previous fix added an is_negative argument to
    wc_price(), which changed the meaning of the filter's unformatted price,
    could be dropped by wc_price_args callbacks that rebuild the array, and
    only differed from a signed number for zero amounts.

    Pass the negated amount instead, the way the customer refund rows, the
    admin refund view and the receipt renderer already do. wc_price() is
    back to trunk, so the filter contract is unchanged: callbacks receive
    the negative number that was passed in, as they do for negative fees.
    Zero-amount coupons and the "Amount already refunded" row on orders
    without refunds now render 0.00 instead of -0.00, which is deliberate.

    The tests assert that each call site hands a negative amount to the
    wc_price filter and that the minus sign lives inside the price markup.

    * Match the order discount cell with the sign inside the price markup

    The order coupon e2e spec looked for a cell named "- $5.00", which
    matched the literal minus and whitespace the order editor used to
    render outside the price span. With the sign inside the wc_price()
    markup the accessible name is "-$5.00" and the locator found nothing,
    failing both coupon tests in CI.

    Match the cell with a regex that accepts either shape.

    * fix: Refine negative price formatting after review

    Cast refunded totals before negation to retain numeric coercion at the
    price formatter boundary.

    Use the test base's fixture and hook cleanup, share coupon rendering
    setup, and assert the exact negative discount text in the browser tests.
    These changes address review feedback without changing the intended
    localized negative-price behavior.

    Refs #40085

    * test: Move coupon formatting tests to the main suite

    New tests belong in the main PHP suite, including tests for legacy
    includes code. Move the two coupon formatting tests and their shared
    rendering helper out of the legacy cart-functions class.

    Preserve the assertions and fixture behavior. Both suites use the same
    bootstrap and base class, so the move requires no configuration changes.

    Refs #40085

    ---------

    Co-authored-by: Vlad Olaru <vlad@thinkwritecode.com>
    Co-authored-by: Vlad Olaru <vlad.olaru@automattic.com>
    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-40085-wc-price-negative-formatting b/plugins/woocommerce/changelog/fix-40085-wc-price-negative-formatting
new file mode 100644
index 00000000000..32757b3f110
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-40085-wc-price-negative-formatting
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Format discount and refund amounts as negative prices with localized markup.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
index bdfe49341fb..ef7cf424a1d 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
@@ -330,7 +330,7 @@ class WC_Admin_List_Table_Orders extends WC_Admin_List_Table {
 					case 'total':
 						$html .= wc_price( $item->get_total(), $price_args );
 						if ( $refund ) {
-							$html .= "<div><small class='refunded'>-" . wc_price( $refund['total'], $price_args ) . '</small></div><br/>';
+							$html .= "<div><small class='refunded'>" . wc_price( -$refund['total'], $price_args ) . '</small></div><br/>';
 						}
 						break;
 					default:
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
index e21000f3715..182a2a8a541 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-items.php
@@ -178,8 +178,8 @@ if ( wc_tax_enabled() ) {
 			<tr>
 				<td class="label"><?php esc_html_e( 'Discount:', 'woocommerce' ); ?></td>
 				<td width="1%"></td>
-				<td class="total">-
-					<?php echo wc_price( $order->get_total_discount(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+				<td class="total">
+					<?php echo wc_price( -$order->get_total_discount(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
 				</td>
 			</tr>
 		<?php endif; ?>
@@ -272,7 +272,7 @@ if ( wc_tax_enabled() ) {
 			<tr>
 				<td class="label refunded-total"><?php esc_html_e( 'Refunded', 'woocommerce' ); ?>:</td>
 				<td width="1%"></td>
-				<td class="total refunded-total">-<?php echo wc_price( $order->get_total_refunded(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
+				<td class="total refunded-total"><?php echo wc_price( - (float) $order->get_total_refunded(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
 			</tr>

 			<?php do_action( 'woocommerce_admin_order_totals_after_refunded', $order->get_id() ); ?>
@@ -357,7 +357,7 @@ if ( wc_tax_enabled() ) {
 		<?php endif; ?>
 		<tr>
 			<td class="label"><?php esc_html_e( 'Amount already refunded', 'woocommerce' ); ?>:</td>
-			<td class="total">-<?php echo wc_price( $order->get_total_refunded(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
+			<td class="total"><?php echo wc_price( - (float) $order->get_total_refunded(), array( 'currency' => $order->get_currency() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
 		</tr>
 		<tr>
 			<td class="label"><?php esc_html_e( 'Total available to refund', 'woocommerce' ); ?>:</td>
diff --git a/plugins/woocommerce/includes/wc-cart-functions.php b/plugins/woocommerce/includes/wc-cart-functions.php
index ed08cc45e0c..a6426533cae 100644
--- a/plugins/woocommerce/includes/wc-cart-functions.php
+++ b/plugins/woocommerce/includes/wc-cart-functions.php
@@ -316,7 +316,7 @@ function wc_cart_totals_coupon_html( $coupon ) {
 	}

 	$amount               = WC()->cart->get_coupon_discount_amount( $coupon->get_code(), WC()->cart->display_cart_ex_tax );
-	$discount_amount_html = '-' . wc_price( $amount );
+	$discount_amount_html = wc_price( -$amount );

 	if ( $coupon->get_free_shipping() && empty( $amount ) ) {
 		$discount_amount_html = __( 'Free shipping coupon', 'woocommerce' );
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
index 858f7fdd809..34118a4dc50 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
@@ -110,7 +110,10 @@ test.describe(
 				page.getByRole( 'cell', { name: 'Discount:', exact: true } )
 			).toBeVisible();
 			await expect(
-				page.getByRole( 'cell', { name: `- $${ couponAmount }.00` } )
+				page.getByRole( 'cell', {
+					name: `-$${ couponAmount }.00`,
+					exact: true,
+				} )
 			).toBeVisible();
 			await expect(
 				page.getByRole( 'cell', {
@@ -135,7 +138,8 @@ test.describe(
 			).toBeVisible();
 			await expect(
 				page.getByRole( 'cell', {
-					name: `- $${ couponAmount }.00`,
+					name: `-$${ couponAmount }.00`,
+					exact: true,
 				} )
 			).toBeVisible();
 			await expect(
diff --git a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
index 0df237acba4..b8f39a97c17 100644
--- a/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/list-tables/class-wc-admin-list-table-orders-test.php
@@ -489,6 +489,57 @@ class WC_Admin_List_Table_Orders_Test extends WC_Unit_Test_Case {
 		unset( $GLOBALS['pagenow'] );
 	}

+	/**
+	 * @testdox Order previews pass refund totals to wc_price as negative amounts.
+	 */
+	public function test_order_preview_passes_negative_refund_total_to_wc_price(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->calculate_totals();
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'amount'        => 5,
+				'order_id'      => $order->get_id(),
+				'line_items'    => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 5,
+						'refund_tax'   => array(),
+					),
+				),
+				'restock_items' => false,
+			)
+		);
+
+		$price_filter = static function ( $price_html, $formatted_price, $args, $unformatted_price, $original_price ) {
+			return (float) $original_price < 0 ? 'signed-price:' . $original_price . ':' . $unformatted_price : $price_html;
+		};
+		add_filter( 'wc_price', $price_filter, 10, 5 );
+
+		$preview_html = WC_Admin_List_Table_Orders::get_order_preview_item_html( $order );
+
+		remove_filter( 'wc_price', $price_filter );
+		$order->delete( true );
+		$product->delete( true );
+
+		$document       = new DOMDocument();
+		$previous_state = libxml_use_internal_errors( true );
+		$loaded         = $document->loadHTML( '<!DOCTYPE html><html><body>' . $preview_html . '</body></html>' );
+		libxml_clear_errors();
+		libxml_use_internal_errors( $previous_state );
+
+		$this->assertTrue( $loaded, 'The order preview output should be valid enough for DOM parsing.' );
+
+		$xpath          = new DOMXPath( $document );
+		$refunded_nodes = $xpath->query( "//small[contains(concat(' ', normalize-space(@class), ' '), ' refunded ') and normalize-space(.) = 'signed-price:-5:-5']" );
+
+		$this->assertNotFalse( $refunded_nodes, 'The refunded price XPath query should be valid.' );
+		$this->assertSame( 1, $refunded_nodes->length, 'The preview should pass the refund total to the wc_price filter as a negative amount.' );
+	}
+
 	/**
 	 * @testdox Should not warn or drop orders when the m parameter is not a string.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php
new file mode 100644
index 00000000000..1ce5812e17d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/views/html-order-items-test.php
@@ -0,0 +1,106 @@
+<?php
+/**
+ * Tests for the order items meta box view.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Tests for the order items meta box view.
+ */
+class WC_Admin_Meta_Box_Order_Items_View_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Order item totals pass discount and refund amounts to wc_price as negative amounts.
+	 */
+	public function test_discount_and_refund_totals_pass_negative_amounts_to_wc_price(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'price'         => 100,
+				'regular_price' => 100,
+			)
+		);
+		$order   = wc_create_order();
+		$item_id = $order->add_product( $product, 1 );
+		$order->set_discount_total( 10 );
+		$order->set_total( 90 );
+		$order->save();
+
+		wc_create_refund(
+			array(
+				'amount'        => 25,
+				'order_id'      => $order->get_id(),
+				'line_items'    => array(
+					$item_id => array(
+						'qty'          => 1,
+						'refund_total' => 25,
+						'refund_tax'   => array(),
+					),
+				),
+				'restock_items' => false,
+			)
+		);
+		$order = wc_get_order( $order->get_id() );
+
+		$price_filter = static function ( $price_html, $formatted_price, $args, $unformatted_price, $original_price ) {
+			return (float) $original_price < 0 ? 'signed-price:' . $original_price . ':' . $unformatted_price : $price_html;
+		};
+		add_filter( 'wc_price', $price_filter, 10, 5 );
+
+		ob_start();
+		include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-items.php';
+		$order_items_html = ob_get_clean();
+
+		$xpath          = $this->get_xpath_for_html( $order_items_html );
+		$discount_nodes = $xpath->query( "//td[contains(concat(' ', normalize-space(@class), ' '), ' total ') and normalize-space(.) = 'signed-price:-10:-10']" );
+		$refund_nodes   = $xpath->query( "//td[contains(concat(' ', normalize-space(@class), ' '), ' total ') and normalize-space(.) = 'signed-price:-25:-25']" );
+
+		$this->assertNotFalse( $discount_nodes, 'The discount price XPath query should be valid.' );
+		$this->assertNotFalse( $refund_nodes, 'The refund price XPath query should be valid.' );
+		$this->assertSame( 1, $discount_nodes->length, 'The discount row should pass the discount to the wc_price filter as a negative amount.' );
+		$this->assertSame( 2, $refund_nodes->length, 'Both refund total cells should pass the refunded total to the wc_price filter as a negative amount.' );
+	}
+
+	/**
+	 * @testdox The refund form shows a zero refunded total without a minus sign.
+	 */
+	public function test_refund_form_renders_zero_refunded_total_without_sign(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$order   = wc_create_order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+		$order->save();
+
+		ob_start();
+		include WC_ABSPATH . 'includes/admin/meta-boxes/views/html-order-items.php';
+		$order_items_html = ob_get_clean();
+
+		$xpath          = $this->get_xpath_for_html( $order_items_html );
+		$refunded_nodes = $xpath->query( "//tr[td[contains(concat(' ', normalize-space(@class), ' '), ' label ') and normalize-space(.) = 'Amount already refunded:']]/td[contains(concat(' ', normalize-space(@class), ' '), ' total ')]" );
+
+		$this->assertNotFalse( $refunded_nodes, 'The refunded total XPath query should be valid.' );
+		$this->assertSame( 1, $refunded_nodes->length, 'The refund form should show the amount already refunded.' );
+		$this->assertSame( '$0.00', trim( $refunded_nodes->item( 0 )->textContent ), 'A zero refunded total should render without a minus sign.' );
+	}
+
+	/**
+	 * Parses an HTML fragment and returns an XPath object for it.
+	 *
+	 * @param string $html The HTML fragment.
+	 * @return DOMXPath
+	 */
+	private function get_xpath_for_html( string $html ): DOMXPath {
+		$document       = new DOMDocument();
+		$previous_state = libxml_use_internal_errors( true );
+		$loaded         = $document->loadHTML( '<!DOCTYPE html><html><body>' . $html . '</body></html>' );
+		libxml_clear_errors();
+		libxml_use_internal_errors( $previous_state );
+
+		$this->assertTrue( $loaded, 'The order items output should be valid enough for DOM parsing.' );
+
+		return new DOMXPath( $document );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-cart-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-cart-functions-test.php
new file mode 100644
index 00000000000..48ace107efe
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/wc-cart-functions-test.php
@@ -0,0 +1,56 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for cart functions.
+ *
+ * @package WooCommerce\Tests\Cart
+ */
+
+/**
+ * Tests for coupon discount formatting in cart totals.
+ */
+class WC_Cart_Functions_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Coupon totals pass the discount to wc_price as a negative amount.
+	 */
+	public function test_wc_cart_totals_coupon_html_passes_negative_amount_to_wc_price(): void {
+		$price_filter = static function ( $price_html, $formatted_price, $args, $unformatted_price, $original_price ) {
+			return 'signed-price:' . $original_price . ':' . $unformatted_price;
+		};
+		add_filter( 'wc_price', $price_filter, 10, 5 );
+
+		$coupon_html = $this->get_coupon_html_for_cart();
+
+		$this->assertStringStartsWith( 'signed-price:-1:-1 ', $coupon_html, 'The wc_price filter should receive the coupon discount as a negative amount.' );
+	}
+
+	/**
+	 * @testdox Coupon totals render the minus sign inside the price markup.
+	 */
+	public function test_wc_cart_totals_coupon_html_renders_sign_inside_price_markup(): void {
+		$coupon_html = $this->get_coupon_html_for_cart();
+
+		$this->assertStringStartsWith( '<span class="woocommerce-Price-amount amount">-', $coupon_html, 'The minus sign should be part of the price markup, not prepended outside it.' );
+	}
+
+	/**
+	 * Creates a cart with a coupon and renders its discount HTML.
+	 *
+	 * @return string
+	 */
+	private function get_coupon_html_for_cart(): string {
+		$product = WC_Helper_Product::create_simple_product();
+		$coupon  = WC_Helper_Coupon::create_coupon();
+
+		WC()->cart->empty_cart();
+		WC()->cart->add_to_cart( $product->get_id() );
+		WC()->cart->apply_coupon( $coupon->get_code() );
+		WC()->cart->calculate_totals();
+
+		ob_start();
+		wc_cart_totals_coupon_html( $coupon );
+		return ob_get_clean();
+	}
+}