Commit 1ea09ef2dac for woocommerce
commit 1ea09ef2dac7cee1b3068d2143399d2f75596f15
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Mon Aug 24 15:17:04 2026 +0300
Calculate coupon discounts from manually edited order line item totals (#67731)
* Calculate coupon discounts from manually edited order line item totals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove @since tags from private helper methods
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Cover usage-limit restore and stacked-coupon paths with tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Read line item values in edit context when syncing subtotals
* Keep per-rate line item taxes in sync when adopting an edited total
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-28591-coupon-discount-on-edited-order-totals b/plugins/woocommerce/changelog/fix-28591-coupon-discount-on-edited-order-totals
new file mode 100644
index 00000000000..dfe3af092b5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-28591-coupon-discount-on-edited-order-totals
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Calculate coupon discounts from manually edited order line item totals instead of the original prices, and stop coupon application from discarding those manual edits.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 5192ba7e798..e8e1b4394b0 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1441,6 +1441,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Apply a coupon to the order and recalculate totals.
*
* @since 3.2.0
+ * @since 11.2.0 When no coupons are applied yet, line items whose totals were manually
+ * edited have their subtotals synced to those totals first, so discounts
+ * are calculated from the edited prices rather than the original ones.
* @param string|WC_Coupon $raw_coupon Coupon code or object.
* @return true|WP_Error True if applied, error if not.
*/
@@ -1473,10 +1476,18 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
}
+ // With no coupons applied, a line total differing from its subtotal is a manual price
+ // adjustment. Adopt it as the new pre-discount price, otherwise discounts would be
+ // calculated from the original price and recalculations would discard the adjustment.
+ // With coupons already applied this is skipped: the difference also contains their
+ // discounts and the manual portion cannot be separated out.
+ $original_subtotals = empty( $applied_coupons ) ? $this->sync_subtotals_with_manually_edited_totals() : array();
+
$discounts = new WC_Discounts( $this );
$applied = $discounts->apply_coupon( $coupon );
if ( is_wp_error( $applied ) ) {
+ $this->restore_item_subtotals( $original_subtotals );
return $applied;
}
@@ -1486,6 +1497,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( $data_store && 0 === $this->get_customer_id() ) {
$usage_count = $data_store->get_usage_by_email( $coupon, $this->get_billing_email() );
if ( 0 < $coupon->get_usage_limit_per_user() && $usage_count >= $coupon->get_usage_limit_per_user() ) {
+ $this->restore_item_subtotals( $original_subtotals );
return new WP_Error(
'invalid_coupon',
$coupon->get_coupon_error( 106 ),
@@ -1529,6 +1541,79 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return true;
}
+ /**
+ * Sync the subtotal of line items whose total was manually edited, adopting the edited
+ * total as the new pre-discount price that discounts are calculated from.
+ *
+ * Only called when the order has no coupons applied, since applied coupons make the
+ * subtotal/total difference ambiguous (coupon discount vs manual adjustment).
+ *
+ * @return array Original tax and subtotal values of the changed items, keyed by item ID.
+ */
+ private function sync_subtotals_with_manually_edited_totals() {
+ $original_subtotals = array();
+
+ foreach ( $this->get_items() as $item_id => $item ) {
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ continue;
+ }
+
+ if ( (float) $item->get_subtotal( 'edit' ) === (float) $item->get_total( 'edit' ) && (float) $item->get_subtotal_tax( 'edit' ) === (float) $item->get_total_tax( 'edit' ) ) {
+ continue;
+ }
+
+ $taxes = $item->get_taxes( 'edit' );
+
+ $original_subtotals[ $item_id ] = array(
+ 'subtotal' => $item->get_subtotal( 'edit' ),
+ 'subtotal_tax' => $item->get_subtotal_tax( 'edit' ),
+ 'total_tax' => $item->get_total_tax( 'edit' ),
+ 'taxes' => $taxes,
+ );
+
+ $item->set_subtotal( $item->get_total( 'edit' ) );
+
+ // set_taxes() keeps the per-rate tax array and the subtotal_tax/total_tax totals in
+ // sync, so consumers see the same value whichever one they read. It is skipped when
+ // there is no per-rate data, since it would then zero out the stored tax totals.
+ if ( ! empty( $taxes['total'] ) ) {
+ $taxes['subtotal'] = $taxes['total'];
+ $item->set_taxes( $taxes );
+ } else {
+ $item->set_subtotal_tax( $item->get_total_tax( 'edit' ) );
+ }
+ }
+
+ return $original_subtotals;
+ }
+
+ /**
+ * Restore item subtotals changed by sync_subtotals_with_manually_edited_totals(), so
+ * that a failed coupon application leaves the in-memory order unchanged.
+ *
+ * @param array $original_subtotals Original tax and subtotal values, keyed by item ID.
+ * @return void
+ */
+ private function restore_item_subtotals( array $original_subtotals ) {
+ foreach ( $original_subtotals as $item_id => $original ) {
+ $item = $this->get_item( $item_id, false );
+
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ continue;
+ }
+
+ // Restoring the per-rate taxes recomputes both tax totals, so it runs before the
+ // stored totals are put back.
+ if ( ! empty( $original['taxes']['total'] ) ) {
+ $item->set_taxes( $original['taxes'] );
+ }
+
+ $item->set_subtotal( $original['subtotal'] );
+ $item->set_subtotal_tax( $original['subtotal_tax'] );
+ $item->set_total_tax( $original['total_tax'] );
+ }
+ }
+
/**
* Remove a coupon from the order and recalculate totals.
*
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
index 6c0db8103ef..31c4f471edc 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-order-test.php
@@ -319,6 +319,241 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertEquals( $coupon_code, $coupon_info[1] );
}
+ /**
+ * Create a pending order with one $100 product whose line total was manually edited to $50.
+ *
+ * @return WC_Order
+ */
+ private function create_order_with_manually_edited_total() {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100 );
+ $product->save();
+
+ $order = wc_create_order();
+ $order->add_product( $product, 1 );
+ $order->set_status( OrderStatus::PENDING );
+ $order->calculate_totals();
+
+ foreach ( $order->get_items() as $item ) {
+ $item->set_total( 50 );
+ $item->save();
+ }
+ $order->calculate_totals();
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * @testdox Applying a coupon calculates the discount from a manually edited line total instead of the original price.
+ */
+ public function test_apply_coupon_uses_manually_edited_line_total() {
+ $coupon = WC_Helper_Coupon::create_coupon(
+ 'percent_coupon_28591',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ )
+ );
+ $order = $this->create_order_with_manually_edited_total();
+
+ $this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 50, $item->get_subtotal(), 'Edited line total should become the new pre-discount price' );
+ $this->assertEquals( 45, $item->get_total(), 'Discount should be taken off the edited price' );
+ $this->assertEquals( 5, $order->get_discount_total(), 'Discount should be 10% of the edited price' );
+ $this->assertEquals( 45, $order->get_total() );
+ }
+
+ /**
+ * @testdox A failed coupon application leaves manually edited line items unchanged.
+ */
+ public function test_apply_coupon_failure_keeps_manually_edited_line_items() {
+ WC_Helper_Coupon::create_coupon(
+ 'expired_coupon_28591',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ 'expiry_date' => gmdate( 'Y-m-d', time() - 2 * DAY_IN_SECONDS ),
+ )
+ );
+ $order = $this->create_order_with_manually_edited_total();
+
+ $this->assertWPError( $order->apply_coupon( 'expired_coupon_28591' ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 100, $item->get_subtotal(), 'Failed coupon application should not change the subtotal' );
+ $this->assertEquals( 50, $item->get_total(), 'Failed coupon application should not change the total' );
+ }
+
+ /**
+ * @testdox Removing a coupon restores the manually edited line total, not the original price.
+ */
+ public function test_remove_coupon_restores_manually_edited_line_total() {
+ $coupon = WC_Helper_Coupon::create_coupon(
+ 'percent_coupon_28591_remove',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ )
+ );
+ $order = $this->create_order_with_manually_edited_total();
+
+ $this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+ $this->assertTrue( $order->remove_coupon( $coupon->get_code() ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 50, $item->get_subtotal() );
+ $this->assertEquals( 50, $item->get_total(), 'Removing the coupon should restore the edited price, not the original one' );
+ $this->assertEquals( 50, $order->get_total() );
+ }
+
+ /**
+ * @testdox A guest usage-limit rejection leaves manually edited line items unchanged.
+ */
+ public function test_apply_coupon_usage_limit_rejection_keeps_manually_edited_line_items() {
+ $guest_email = 'guest28591@example.com';
+ $coupon = WC_Helper_Coupon::create_coupon(
+ 'limited_coupon_28591',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ 'usage_limit_per_user' => '1',
+ )
+ );
+ $coupon->increase_usage_count( $guest_email );
+
+ $order = $this->create_order_with_manually_edited_total();
+ $order->set_billing_email( $guest_email );
+ $order->save();
+
+ $this->assertWPError( $order->apply_coupon( $coupon->get_code() ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 100, $item->get_subtotal(), 'Usage-limit rejection should not change the subtotal' );
+ $this->assertEquals( 50, $item->get_total(), 'Usage-limit rejection should not change the total' );
+ }
+
+ /**
+ * @testdox A second coupon stacks on the manually edited price without re-syncing subtotals.
+ */
+ public function test_apply_second_coupon_stacks_on_manually_edited_line_total() {
+ $percent_coupon = WC_Helper_Coupon::create_coupon(
+ 'percent_coupon_28591_stack',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ )
+ );
+ $fixed_coupon = WC_Helper_Coupon::create_coupon(
+ 'fixed_coupon_28591_stack',
+ array(
+ 'discount_type' => 'fixed_cart',
+ 'coupon_amount' => '5',
+ )
+ );
+ $order = $this->create_order_with_manually_edited_total();
+
+ $this->assertTrue( $order->apply_coupon( $percent_coupon->get_code() ) );
+ $this->assertTrue( $order->apply_coupon( $fixed_coupon->get_code() ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 50, $item->get_subtotal(), 'Second coupon application should not re-sync the subtotal' );
+ $this->assertEquals( 40, $item->get_total(), 'Both discounts should be taken off the edited price' );
+ $this->assertEquals( 10, $order->get_discount_total() );
+ $this->assertEquals( 40, $order->get_total() );
+ }
+
+ /**
+ * Create a pending order with a 10% tax rate and one $100 product whose line total was
+ * manually edited to $50.
+ *
+ * @return WC_Order
+ */
+ private function create_taxed_order_with_manually_edited_total() {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'VAT',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_order' => '1',
+ )
+ );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_regular_price( 100 );
+ $product->save();
+
+ $order = wc_create_order();
+ $order->add_product( $product, 1 );
+ $order->set_status( OrderStatus::PENDING );
+ $order->calculate_totals( true );
+
+ foreach ( $order->get_items() as $item ) {
+ $item->set_total( 50 );
+ $item->save();
+ }
+ $order->calculate_totals( true );
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * @testdox Applying a coupon syncs the per-rate subtotal taxes with the manually edited total.
+ */
+ public function test_apply_coupon_syncs_line_item_taxes_with_manually_edited_total() {
+ $coupon = WC_Helper_Coupon::create_coupon(
+ 'percent_coupon_28591_tax',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ )
+ );
+ $order = $this->create_taxed_order_with_manually_edited_total();
+
+ $this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+
+ $item = current( $order->get_items() );
+ $taxes = $item->get_taxes();
+ $this->assertEquals( 50, $item->get_subtotal() );
+ $this->assertEquals( 5, $item->get_subtotal_tax(), 'Subtotal tax should follow the edited price' );
+ $this->assertEquals( 5, array_sum( $taxes['subtotal'] ), 'Per-rate subtotal taxes should match the subtotal tax total' );
+ $this->assertEquals( 45, $item->get_total() );
+ $this->assertEquals( 4.5, $item->get_total_tax() );
+ $this->assertEquals( 49.5, $order->get_total() );
+ }
+
+ /**
+ * @testdox A failed coupon application restores the per-rate taxes of manually edited line items.
+ */
+ public function test_apply_coupon_failure_keeps_manually_edited_line_item_taxes() {
+ WC_Helper_Coupon::create_coupon(
+ 'expired_coupon_28591_tax',
+ array(
+ 'discount_type' => 'percent',
+ 'coupon_amount' => '10',
+ 'expiry_date' => gmdate( 'Y-m-d', time() - 2 * DAY_IN_SECONDS ),
+ )
+ );
+ $order = $this->create_taxed_order_with_manually_edited_total();
+
+ $original_taxes = current( $order->get_items() )->get_taxes();
+
+ $this->assertWPError( $order->apply_coupon( 'expired_coupon_28591_tax' ) );
+
+ $item = current( $order->get_items() );
+ $this->assertEquals( 100, $item->get_subtotal() );
+ $this->assertEquals( 10, $item->get_subtotal_tax(), 'Failed coupon application should not change the subtotal tax' );
+ $this->assertEquals( 50, $item->get_total() );
+ $this->assertEquals( 5, $item->get_total_tax(), 'Failed coupon application should not change the total tax' );
+ $this->assertEquals( $original_taxes, $item->get_taxes(), 'Failed coupon application should restore the per-rate taxes' );
+ }
+
/**
* Test for get_discount_to_display which must return a value
* with and without tax whatever the setting of the options.