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.