Commit f5de242d115 for woocommerce

commit f5de242d1157c63e493bdcd75961056d7ef6f40d
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date:   Wed Oct 21 18:33:05 2026 +0200

    Fix percentage order fees calculated on tax-inclusive total (#68623)

    Percentage fees added in the order editor were calculated on
    get_total(), which includes tax, shipping and any fee already on the
    order, and the fee was then taxed again. A 10% fee on a $100 order with
    20% tax came to $12.00 + $2.40 instead of $10.00 + $2.00.
    Calculate the percentage on the order items after coupon discounts,
    excluding tax, shipping and other fees, using the stored discount
    total, and round the result to the store's price decimals. The fee
    prompt now says amounts are before tax and percentages apply to the
    order items after discounts.

diff --git a/plugins/woocommerce/changelog/38830-percentage-order-fees-ex-tax b/plugins/woocommerce/changelog/38830-percentage-order-fees-ex-tax
new file mode 100644
index 00000000000..d55fa42c9be
--- /dev/null
+++ b/plugins/woocommerce/changelog/38830-percentage-order-fees-ex-tax
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Calculate percentage fees added in the order editor on the order items before tax, without shipping or other fees.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 3924ec1fe7a..c9ac1862db5 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -721,7 +721,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
 					'i18n_order_note_added'                           => __( 'Order note added.', 'woocommerce' ),
 					'i18n_customer_order_note_added'                  => __( 'Order note added and emailed to the customer.', 'woocommerce' ),
 					'i18n_apply_coupon'                               => __( 'Enter a coupon code to apply. Discounts are applied to line totals, before taxes.', 'woocommerce' ),
-					'i18n_add_fee'                                    => __( 'Enter a fixed amount or percentage to apply as a fee.', 'woocommerce' ),
+					'i18n_add_fee'                                    => __( 'Enter a fixed amount or percentage to apply as a fee.', 'woocommerce' ) . ' ' . __( 'Amounts are before tax, and percentages apply to the order items after discounts.', 'woocommerce' ),
 					'i18n_attribute_name_placeholder'                 => __( 'New attribute', 'woocommerce' ),
 					'i18n_product_simple_tip'                         => __( '<b>Simple –</b> covers the vast majority of any products you may sell. Simple products are shipped and have no options. For example, a book.', 'woocommerce' ),
 					'i18n_product_grouped_tip'                        => __( '<b>Grouped –</b> a collection of related products that can be purchased individually and only consist of simple products. For example, a set of six drinking glasses.', 'woocommerce' ),
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 95fb13f26b3..d761f4f31f3 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -1372,11 +1372,13 @@ class WC_AJAX {
 			);

 			if ( strstr( $amount, '%' ) ) {
-				// We need to calculate totals first, so that $order->get_total() is correct.
+				// Refresh totals so the discount total is current. The percentage applies to
+				// the items after discounts, excluding tax, shipping and other fees.
 				$order->calculate_totals( false );
 				$formatted_amount = $amount;
 				$percent          = floatval( trim( $amount, '%' ) );
-				$amount           = $order->get_total() * ( $percent / 100 );
+				$base             = (float) $order->get_subtotal() - (float) $order->get_discount_total( 'edit' );
+				$amount           = NumberUtil::round( $base * ( $percent / 100 ), wc_get_price_decimals() );
 			} else {
 				$amount           = floatval( $amount );
 				$formatted_amount = wc_price( $amount, array( 'currency' => $order->get_currency() ) );
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 4c18626fad9..5aa7d1da368 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -2943,6 +2943,212 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		$this->assertEquals( $original_qty, $fresh_item->get_quantity() );
 	}

+	/**
+	 * @testdox Percentage order fees added via AJAX are calculated on the order items excluding tax.
+	 *
+	 * @testWith ["25%", 25.0, 5.0, 150.0]
+	 *           ["-25%", -25.0, -5.0, 90.0]
+	 *
+	 * @param string $amount          Fee amount posted to the AJAX handler.
+	 * @param float  $expected_fee    Expected fee line total (ex tax).
+	 * @param float  $expected_fee_tax Expected fee tax.
+	 * @param float  $expected_total  Expected order grand total.
+	 */
+	public function test_add_order_fee_percentage_uses_items_excluding_tax( string $amount, float $expected_fee, float $expected_fee_tax, float $expected_total ): void {
+		$order = $this->create_order_with_20_percent_tax();
+
+		$fee = $this->add_order_fee_via_ajax( $order, $amount );
+
+		$this->assertEquals( $expected_fee, (float) $fee->get_total(), 'Fee total should be a percentage of the items excluding tax.' );
+		$this->assertEquals( $expected_fee_tax, (float) $fee->get_total_tax(), 'Fee tax should be calculated on the fee amount.' );
+		$this->assertEquals( $expected_total, (float) $fee->get_order()->get_total(), 'Order total should include the fee and its tax.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees leave shipping out of their base.
+	 */
+	public function test_add_order_fee_percentage_excludes_shipping_from_base(): void {
+		$order = $this->create_order_with_20_percent_tax();
+
+		$shipping = new WC_Order_Item_Shipping();
+		$shipping->set_method_title( 'Flat rate' );
+		$shipping->set_method_id( 'flat_rate' );
+		$shipping->set_total( 10 );
+		$order->add_item( $shipping );
+		$order->calculate_totals( true );
+		$order->save();
+
+		$fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+		// The base is the items (100), not items plus shipping (110), so 10% is 10, not 11.
+		$this->assertEquals( 10.0, (float) $fee->get_total(), 'Percentage fee should leave shipping out of its base.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees leave existing fees out of their base.
+	 */
+	public function test_add_order_fee_percentage_excludes_existing_fees_from_base(): void {
+		$order = $this->create_order_with_20_percent_tax();
+		$this->add_order_fee_via_ajax( $order, '10%' );
+
+		$fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+		// The first fee (10) is not part of the base, so the second 10% is 10 again, not 11.
+		$this->assertEquals( 10.0, (float) $fee->get_total(), 'Percentage fee should leave existing fees out of its base.' );
+		$this->assertEquals( 144.0, (float) $fee->get_order()->get_total(), 'Order total should include both fees and their tax.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees are calculated on the items after coupon discounts.
+	 */
+	public function test_add_order_fee_percentage_uses_items_after_discounts(): void {
+		$order = $this->create_order_with_20_percent_tax();
+		$this->apply_10_percent_coupon( $order );
+
+		$fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+		// The coupon brings the items to 90, so 10% is 9.
+		$this->assertEquals( 9.0, (float) $fee->get_total(), 'Percentage fee should use the items after discounts.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees use the stored discount total, not a filtered one.
+	 */
+	public function test_add_order_fee_percentage_ignores_filtered_discount_total(): void {
+		$order = $this->create_order_with_20_percent_tax();
+		$this->apply_10_percent_coupon( $order );
+		add_filter( 'woocommerce_order_get_discount_total', '__return_zero' );
+
+		$fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+		// The stored discount is 10, so the base stays 90 even though the filter reports 0.
+		$this->assertEquals( 9.0, (float) $fee->get_total(), 'Percentage fee should use the stored discount total.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees use the items excluding tax when prices are entered including tax.
+	 */
+	public function test_add_order_fee_percentage_with_prices_including_tax(): void {
+		$order = $this->create_order_with_20_percent_tax( true );
+
+		$fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+		// The $100 price includes 16.67 tax, so the items are 83.33 and 10% is 8.33 plus 1.67 tax.
+		$this->assertEquals( 8.33, (float) $fee->get_total(), 'Fee total should be a percentage of the items excluding tax.' );
+		$this->assertEquals( 1.67, (float) $fee->get_total_tax(), 'Fee tax should be calculated on the fee amount.' );
+		$this->assertEquals( 110.0, (float) $fee->get_order()->get_total(), 'Order total should include the fee and its tax.' );
+	}
+
+	/**
+	 * @testdox Percentage order fees are rounded to the store's price decimals.
+	 */
+	public function test_add_order_fee_percentage_is_rounded_to_price_decimals(): void {
+		$order = $this->create_order_with_20_percent_tax();
+
+		$fee = $this->add_order_fee_via_ajax( $order, '12.345%' );
+
+		// 12.345% of the net 100 is 12.345, which rounds half up to 12.35. Before
+		// rounding was applied the full 12.345 reached the fee line.
+		$this->assertEquals( 12.35, (float) $fee->get_total(), 'Percentage fee should be rounded to the price decimals.' );
+	}
+
+	/**
+	 * @testdox Fixed-amount order fees added via AJAX are stored as posted and taxed.
+	 */
+	public function test_add_order_fee_fixed_amount_is_stored_as_posted(): void {
+		$order = $this->create_order_with_20_percent_tax();
+
+		$fee = $this->add_order_fee_via_ajax( $order, '12.5' );
+
+		$this->assertEquals( 12.5, (float) $fee->get_total(), 'Fixed fee amount should be stored as posted.' );
+		$this->assertEquals( 2.5, (float) $fee->get_total_tax(), 'Fixed fee tax should remain 20% of the fee.' );
+		$this->assertEquals( 135.0, (float) $fee->get_order()->get_total(), 'Order total should include the fixed fee and its tax.' );
+	}
+
+	/**
+	 * Create an order for one $100 product with a 20% tax rate for order fee AJAX tests.
+	 *
+	 * @param bool $prices_include_tax Whether the $100 price includes tax.
+	 * @return WC_Order
+	 */
+	private function create_order_with_20_percent_tax( bool $prices_include_tax = false ): WC_Order {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', $prices_include_tax ? 'yes' : 'no' );
+
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => '',
+				'tax_rate_state'    => '',
+				'tax_rate'          => '20.0000',
+				'tax_rate_name'     => 'Tax',
+				'tax_rate_priority' => '1',
+				'tax_rate_compound' => '0',
+				'tax_rate_shipping' => '1',
+				'tax_rate_order'    => '1',
+				'tax_rate_class'    => '',
+			)
+		);
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->save();
+
+		$order = new WC_Order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals( true );
+		$order->save();
+
+		$this->assertEquals( $prices_include_tax ? 16.67 : 20.0, (float) $order->get_total_tax(), 'Fixture order should have a 20% tax line.' );
+		$this->assertEquals( $prices_include_tax ? 100.0 : 120.0, (float) $order->get_total(), 'Fixture order should have the expected gross total.' );
+
+		return $order;
+	}
+
+	/**
+	 * Apply a 10% percentage coupon to an order for order fee AJAX tests.
+	 *
+	 * @param WC_Order $order Order to apply the coupon to.
+	 */
+	private function apply_10_percent_coupon( WC_Order $order ): void {
+		WC_Helper_Coupon::create_coupon(
+			'tenpercent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+		$this->assertTrue( $order->apply_coupon( 'tenpercent' ), 'Fixture coupon should apply.' );
+	}
+
+	/**
+	 * Add a fee to an order through the admin AJAX handler and return the new fee line.
+	 *
+	 * Asserts that the request succeeds and adds exactly one fee to the order.
+	 *
+	 * @param WC_Order $order  Order to add the fee to.
+	 * @param string   $amount Fee amount as typed in the admin prompt, fixed or with a trailing "%".
+	 * @return WC_Order_Item_Fee
+	 */
+	private function add_order_fee_via_ajax( WC_Order $order, string $amount ): WC_Order_Item_Fee {
+		$fee_count = count( wc_get_order( $order->get_id() )->get_fees() );
+
+		$this->_setRole( 'administrator' );
+		$_POST['security'] = wp_create_nonce( 'order-item' );
+		$_POST['order_id'] = $order->get_id();
+		$_POST['amount']   = $amount;
+
+		$response = $this->do_ajax( 'woocommerce_add_order_fee' );
+
+		unset( $_POST['security'], $_POST['order_id'], $_POST['amount'] );
+
+		$this->assertTrue( $response['success'] ?? false, 'Adding a fee via AJAX should succeed.' );
+
+		$fees = wc_get_order( $order->get_id() )->get_fees();
+		$this->assertCount( $fee_count + 1, $fees, 'Exactly one fee should be added to the order.' );
+
+		return end( $fees );
+	}
+
 	/**
 	 * The Grant access product search must honor the include/exclude parameters so
 	 * already-granted products do not reappear in the results.