Commit 29174eb52af for woocommerce
commit 29174eb52af5ac65312cc55259234607b2a853f4
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date: Wed Nov 4 17:24:39 2026 +0100
Fix percentage order fees reading a filtered order subtotal (#70102)
#68623 based percentage fees added in the order editor on get_subtotal()
minus the stored discount total. Plugins can change get_subtotal()
through the woocommerce_order_get_subtotal filter, for example to leave
out free BOGO items. That filter could change the fee, and a lowered
subtotal minus an unfiltered discount could subtract the same amount
twice.
Base the fee on the line items' stored totals, read in edit context so
the subtotal, discount and line total filters don't apply. Round them
with get_rounded_items_total(), as the order subtotal does, so the base
matches the items subtotal the order shows.
diff --git a/plugins/woocommerce/changelog/wooairr-595-percentage-fee-line-items b/plugins/woocommerce/changelog/wooairr-595-percentage-fee-line-items
new file mode 100644
index 00000000000..9335b077b5f
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-595-percentage-fee-line-items
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Read the percentage fee base from order line items; refines an unreleased change.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 4d796913170..70d353414da 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -1408,13 +1408,23 @@ class WC_AJAX {
);
if ( strstr( $amount, '%' ) ) {
- // Refresh totals so the discount total is current. The percentage applies to
- // the items after discounts, excluding tax, shipping and other fees.
+ // Not needed for the base, which reads stored line totals. Kept so callbacks on the order
+ // totals hooks still run before the fee is added.
$order->calculate_totals( false );
$formatted_amount = $amount;
$percent = floatval( trim( $amount, '%' ) );
- $base = (float) $order->get_subtotal() - (float) $order->get_discount_total( 'edit' );
- $amount = NumberUtil::round( $base * ( $percent / 100 ), wc_get_price_decimals() );
+
+ // The percentage applies to the line items after discounts, excluding tax, shipping and
+ // other fees. Read their stored totals so a filtered order subtotal can't change the fee,
+ // and round them as the order subtotal does.
+ $line_totals = array();
+ foreach ( $order->get_items() as $item ) {
+ if ( is_callable( array( $item, 'get_total' ) ) ) {
+ $line_totals[] = wc_add_number_precision( (float) $item->get_total( 'edit' ), false );
+ }
+ }
+ $base = wc_remove_number_precision( $order::get_rounded_items_total( $line_totals ) );
+ $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 1734ac62ff5..d0470217ecf 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -3491,7 +3491,7 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
}
/**
- * @testdox Percentage order fees use the stored discount total, not a filtered one.
+ * @testdox Percentage order fees ignore a filtered discount total.
*/
public function test_add_order_fee_percentage_ignores_filtered_discount_total(): void {
$order = $this->create_order_with_20_percent_tax();
@@ -3500,8 +3500,69 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
$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.' );
+ // The line items total 90 after the coupon, so the base stays 90 even though the filter reports no discount.
+ $this->assertEquals( 9.0, (float) $fee->get_total(), 'Percentage fee should ignore a filtered discount total.' );
+ }
+
+ /**
+ * @testdox Percentage order fees ignore a filtered order subtotal.
+ */
+ public function test_add_order_fee_percentage_ignores_filtered_subtotal(): void {
+ $order = $this->create_order_with_20_percent_tax();
+ add_filter( 'woocommerce_order_get_subtotal', '__return_zero' );
+
+ $fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+ // The line items total 100, so the fee stays 10 even though the filter reports a subtotal of 0.
+ $this->assertEquals( 10.0, (float) $fee->get_total(), 'Percentage fee should ignore a filtered subtotal.' );
+ }
+
+ /**
+ * @testdox Percentage order fees ignore a filtered line item total.
+ */
+ public function test_add_order_fee_percentage_ignores_filtered_item_total(): void {
+ $order = $this->create_order_with_20_percent_tax();
+ add_filter( 'woocommerce_order_item_get_total', '__return_zero' );
+
+ $fee = $this->add_order_fee_via_ajax( $order, '10%' );
+
+ // The stored line total is 100, so the fee stays 10. The filter also zeroes the fee when read in view context.
+ $this->assertEquals( 10.0, (float) $fee->get_total( 'edit' ), 'Percentage fee should ignore a filtered line item total.' );
+ }
+
+ /**
+ * @testdox Percentage order fees round the line items the same way as the order subtotal.
+ *
+ * @testWith ["no", 24.99, 14.99]
+ * ["yes", 25.0, 15.0]
+ *
+ * @param string $round_at_subtotal Value of the "Round tax at subtotal level" option.
+ * @param float $expected_subtotal Expected order items subtotal.
+ * @param float $expected_fee Expected fee line total.
+ */
+ public function test_add_order_fee_percentage_rounds_line_items_like_the_subtotal( string $round_at_subtotal, float $expected_subtotal, float $expected_fee ): void {
+ update_option( 'woocommerce_tax_round_at_subtotal', $round_at_subtotal );
+ $product = WC_Helper_Product::create_simple_product();
+ $order = new WC_Order();
+ for ( $i = 0; $i < 3; $i++ ) {
+ // 8.333333 is the net line total stored for a 10.00 price that includes 20% tax.
+ $order->add_product(
+ $product,
+ 1,
+ array(
+ 'subtotal' => 8.333333,
+ 'total' => 8.333333,
+ )
+ );
+ }
+ $order->calculate_totals( false );
+ $order->save();
+ $this->assertEquals( $expected_subtotal, (float) $order->get_subtotal(), 'Fixture order should show the expected items subtotal.' );
+
+ $fee = $this->add_order_fee_via_ajax( $order, '60%' );
+
+ // Rounding each line gives 8.33 × 3 = 24.99, so 60% is 14.99; without it the base is 24.999999 and 60% is 15.00.
+ $this->assertEquals( $expected_fee, (float) $fee->get_total(), 'Percentage fee should be taken from the items subtotal the order shows.' );
}
/**