Commit b29116dbdbe for woocommerce

commit b29116dbdbed151df7a6a7babf2e57e0f7acc416
Author: Peter Petrov <peter.petrov89@gmail.com>
Date:   Wed Aug 26 17:47:30 2026 +0300

    Fix coupon double-discounting on REST orders with posted line totals (#67979)

    * Fix coupon double-discounting on REST orders with posted line totals

    * Scope the coupon edited-totals changelog entry to the order editor

    * Reword the editor coupon comment to not overclaim the manual-edit guess

    * Rename the coupon test order helper to drop the replacement wording

    * Post a line total the coupon math cannot coincidentally reproduce

    * Align the edited-totals method docblock with what its caller can know

    * Cover the unknown-code rejection path of edited-totals coupon application

    * Tighten the edited-totals coupon changelog entry

    * Trim the changelog comment to the same-cycle justification

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
index dfe3af092b5..4cb35b098cd 100644
--- a/plugins/woocommerce/changelog/fix-28591-coupon-discount-on-edited-order-totals
+++ b/plugins/woocommerce/changelog/fix-28591-coupon-discount-on-edited-order-totals
@@ -1,4 +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.
+Fix coupons applied in the order editor ignoring manually edited line item totals.
diff --git a/plugins/woocommerce/changelog/fix-wooairr-128-editor-scoped-subtotal-sync b/plugins/woocommerce/changelog/fix-wooairr-128-editor-scoped-subtotal-sync
new file mode 100644
index 00000000000..8f2db0715c6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-128-editor-scoped-subtotal-sync
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Same-cycle fix for a #67731 regression, never released.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index e8e1b4394b0..4b258335a8d 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1441,9 +1441,6 @@ 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.
 	 */
@@ -1476,18 +1473,10 @@ 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;
 		}

@@ -1497,7 +1486,6 @@ 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 ),
@@ -1541,6 +1529,33 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 		return true;
 	}

+	/**
+	 * Apply a coupon treating manually edited line item totals as the pre-discount price.
+	 *
+	 * When the order has no coupons yet, line items whose total differs from their subtotal
+	 * adopt that total as the new subtotal, so the discount is calculated from the edited
+	 * price and recalculations keep the manual adjustment. On failure the original subtotals
+	 * are restored. Call this only where a subtotal/total difference is meant to be treated
+	 * as a manual edit, such as the admin order editor; otherwise use apply_coupon().
+	 *
+	 * @since 11.2.0
+	 * @param string|WC_Coupon $raw_coupon Coupon code or object.
+	 * @return true|WP_Error True if applied, error if not.
+	 */
+	public function apply_coupon_using_edited_totals( $raw_coupon ) {
+		// With coupons already applied the subtotal/total difference also contains their
+		// discounts and the manual portion cannot be separated out, so it is left alone.
+		$original_subtotals = empty( $this->get_items( 'coupon' ) ) ? $this->sync_subtotals_with_manually_edited_totals() : array();
+
+		$applied = $this->apply_coupon( $raw_coupon );
+
+		if ( is_wp_error( $applied ) ) {
+			$this->restore_item_subtotals( $original_subtotals );
+		}
+
+		return $applied;
+	}
+
 	/**
 	 * 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.
diff --git a/plugins/woocommerce/src/Internal/Orders/CouponsController.php b/plugins/woocommerce/src/Internal/Orders/CouponsController.php
index 074faa6b004..a567439600c 100644
--- a/plugins/woocommerce/src/Internal/Orders/CouponsController.php
+++ b/plugins/woocommerce/src/Internal/Orders/CouponsController.php
@@ -84,8 +84,11 @@ class CouponsController {
 		$order->calculate_taxes( $calculate_tax_args );
 		$order->calculate_totals( false );

-		$code   = wc_format_coupon_code( wp_unslash( $coupon ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		$result = $order->apply_coupon( $code );
+		$code = wc_format_coupon_code( wp_unslash( $coupon ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+		// A line total differing from its subtotal is treated as a manual price edit here. That is
+		// the editor's best guess: a difference recorded by REST or an extension is adopted the same way.
+		$result = $order->apply_coupon_using_edited_totals( $code );

 		if ( is_wp_error( $result ) ) {
 			throw new Exception( html_entity_decode( wp_strip_all_tags( $result->get_error_message() ) ) );
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 31c4f471edc..5cd2451f3ac 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
@@ -357,7 +357,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 		$order  = $this->create_order_with_manually_edited_total();

-		$this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+		$this->assertTrue( $order->apply_coupon_using_edited_totals( $coupon->get_code() ) );

 		$item = current( $order->get_items() );
 		$this->assertEquals( 50, $item->get_subtotal(), 'Edited line total should become the new pre-discount price' );
@@ -366,6 +366,27 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		$this->assertEquals( 45, $order->get_total() );
 	}

+	/**
+	 * @testdox Plain apply_coupon() calculates the discount from the stored subtotal, leaving edited totals alone.
+	 */
+	public function test_apply_coupon_keeps_stored_subtotals() {
+		$coupon = WC_Helper_Coupon::create_coupon(
+			'percent_coupon_no_sync',
+			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( 100, $item->get_subtotal(), 'apply_coupon() should not adopt the edited total as a new subtotal' );
+		$this->assertEquals( 90, $item->get_total(), 'The discount should be calculated from the stored subtotal' );
+		$this->assertEquals( 10, $order->get_discount_total() );
+	}
+
 	/**
 	 * @testdox A failed coupon application leaves manually edited line items unchanged.
 	 */
@@ -380,13 +401,26 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 		$order = $this->create_order_with_manually_edited_total();

-		$this->assertWPError( $order->apply_coupon( 'expired_coupon_28591' ) );
+		$this->assertWPError( $order->apply_coupon_using_edited_totals( '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 A nonexistent coupon code is rejected before validation and leaves manually edited line items unchanged.
+	 */
+	public function test_apply_coupon_unknown_code_keeps_manually_edited_line_items() {
+		$order = $this->create_order_with_manually_edited_total();
+
+		$this->assertWPError( $order->apply_coupon_using_edited_totals( 'no_such_coupon_28591' ) );
+
+		$item = current( $order->get_items() );
+		$this->assertEquals( 100, $item->get_subtotal(), 'A rejected unknown code should not change the subtotal' );
+		$this->assertEquals( 50, $item->get_total(), 'A rejected unknown code should not change the total' );
+	}
+
 	/**
 	 * @testdox Removing a coupon restores the manually edited line total, not the original price.
 	 */
@@ -400,7 +434,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 		$order  = $this->create_order_with_manually_edited_total();

-		$this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+		$this->assertTrue( $order->apply_coupon_using_edited_totals( $coupon->get_code() ) );
 		$this->assertTrue( $order->remove_coupon( $coupon->get_code() ) );

 		$item = current( $order->get_items() );
@@ -428,7 +462,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		$order->set_billing_email( $guest_email );
 		$order->save();

-		$this->assertWPError( $order->apply_coupon( $coupon->get_code() ) );
+		$this->assertWPError( $order->apply_coupon_using_edited_totals( $coupon->get_code() ) );

 		$item = current( $order->get_items() );
 		$this->assertEquals( 100, $item->get_subtotal(), 'Usage-limit rejection should not change the subtotal' );
@@ -455,8 +489,8 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 		$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() ) );
+		$this->assertTrue( $order->apply_coupon_using_edited_totals( $percent_coupon->get_code() ) );
+		$this->assertTrue( $order->apply_coupon_using_edited_totals( $fixed_coupon->get_code() ) );

 		$item = current( $order->get_items() );
 		$this->assertEquals( 50, $item->get_subtotal(), 'Second coupon application should not re-sync the subtotal' );
@@ -516,7 +550,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		);
 		$order  = $this->create_taxed_order_with_manually_edited_total();

-		$this->assertTrue( $order->apply_coupon( $coupon->get_code() ) );
+		$this->assertTrue( $order->apply_coupon_using_edited_totals( $coupon->get_code() ) );

 		$item  = current( $order->get_items() );
 		$taxes = $item->get_taxes();
@@ -544,7 +578,7 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {

 		$original_taxes = current( $order->get_items() )->get_taxes();

-		$this->assertWPError( $order->apply_coupon( 'expired_coupon_28591_tax' ) );
+		$this->assertWPError( $order->apply_coupon_using_edited_totals( 'expired_coupon_28591_tax' ) );

 		$item = current( $order->get_items() );
 		$this->assertEquals( 100, $item->get_subtotal() );
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 98741c22d3e..ca323a063e3 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -398,6 +398,44 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		$this->assertEquals( 108, $order->get_total() );
 	}

+	/**
+	 * @testdox Applying a coupon in the order editor calculates the discount from a manually edited line total.
+	 */
+	public function test_add_coupon_discount_uses_manually_edited_line_total() {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->save();
+
+		$coupon = new WC_Coupon();
+		$coupon->set_code( '10off-edited' );
+		$coupon->set_discount_type( 'percent' );
+		$coupon->set_amount( 10 );
+		$coupon->save();
+
+		$order = wc_create_order();
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+		foreach ( $order->get_items() as $item ) {
+			$item->set_total( 50 );
+			$item->save();
+		}
+		$order->calculate_totals();
+		$order->save();
+
+		wc_get_container()->get( CouponsController::class )->add_coupon_discount(
+			array(
+				'order_id' => $order->get_id(),
+				'coupon'   => $coupon->get_code(),
+			)
+		);
+
+		$order = wc_get_order( $order->get_id() );
+		$item  = current( $order->get_items() );
+		$this->assertEquals( 50, $item->get_subtotal(), 'The edited line total should become the new pre-discount price' );
+		$this->assertEquals( 45, $item->get_total(), 'The discount should be taken off the edited price' );
+		$this->assertEquals( 45, $order->get_total() );
+	}
+
 	/**
 	 * Describe JSON search, particularly as it relates to handling searches for users in a
 	 * multisite context (it should generally not be possible to retrieve information about
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index 880279c7c28..d72ed3b4f06 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -1584,4 +1584,170 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertSame( 0, $reloaded->get_variation_id(), 'Switching to a simple product by SKU should clear variation_id.' );
 		$this->assertSame( $simple->get_id(), $reloaded->get_product()->get_id(), 'The line item should resolve to the product selected by SKU.' );
 	}
+
+	/**
+	 * Create a pending order with one $100 product, optionally with its line total manually edited.
+	 *
+	 * @param string $billing_email Billing email (the order stays a guest order).
+	 * @param float  $edited_total  Manually edited line total, 0 to keep the original price.
+	 * @return WC_Order
+	 */
+	private function create_guest_order_with_product( string $billing_email, float $edited_total = 0 ): WC_Order {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->save();
+
+		$order = wc_create_order();
+		$order->set_billing_email( $billing_email );
+		$order->add_product( $product, 1 );
+		$order->calculate_totals();
+
+		if ( $edited_total > 0 ) {
+			foreach ( $order->get_items() as $item ) {
+				$item->set_total( $edited_total );
+				$item->save();
+			}
+			$order->calculate_totals();
+		}
+		$order->save();
+
+		return $order;
+	}
+
+	/**
+	 * Dispatch a PUT request replacing the order's coupons with the given codes.
+	 *
+	 * @param int      $order_id Order ID.
+	 * @param string[] $codes    Coupon codes for the request's coupon_lines.
+	 * @return WP_REST_Response
+	 */
+	private function put_coupon_lines( int $order_id, array $codes ): WP_REST_Response {
+		$request = new \WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order_id );
+		$request->set_body_params(
+			array(
+				'coupon_lines' => array_map(
+					function ( $code ) {
+						return array( 'code' => $code );
+					},
+					$codes
+				),
+			)
+		);
+
+		return $this->server->dispatch( $request );
+	}
+
+	/**
+	 * Get the coupon codes currently applied to an order, freshly loaded from the database.
+	 *
+	 * @param int $order_id Order ID.
+	 * @return string[]
+	 */
+	private function get_reloaded_coupon_codes( int $order_id ): array {
+		return array_values(
+			array_map(
+				function ( $coupon ) {
+					return $coupon->get_code();
+				},
+				wc_get_order( $order_id )->get_items( 'coupon' )
+			)
+		);
+	}
+
+	/**
+	 * @testdox A coupon applied via REST calculates the discount from the stored subtotal, not a manually edited total.
+	 */
+	public function test_valid_coupon_applies_to_manually_edited_order(): void {
+		WC_Helper_Coupon::create_coupon(
+			'edited-percent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+
+		$order = $this->create_guest_order_with_product( 'edited-ok-customer@example.com', 50 );
+
+		$response = $this->put_coupon_lines( $order->get_id(), array( 'edited-percent' ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( array( 'edited-percent' ), $this->get_reloaded_coupon_codes( $order->get_id() ) );
+		$this->assertEquals( 90, wc_get_order( $order->get_id() )->get_total(), 'The discount should be taken off the stored subtotal, not the edited total' );
+	}
+
+	/**
+	 * @testdox Creating an order with explicitly posted line totals and a coupon keeps the posted subtotal as the pre-discount price.
+	 */
+	public function test_create_with_posted_line_totals_and_coupon_does_not_double_discount(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( 100 );
+		$product->save();
+		WC_Helper_Coupon::create_coupon(
+			'created-percent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'POST', '/wc/v3/orders' );
+		$request->set_body_params(
+			array(
+				'billing'      => array( 'email' => 'create-coupon-customer@example.com' ),
+				'line_items'   => array(
+					array(
+						'product_id' => $product->get_id(),
+						'quantity'   => 1,
+						'subtotal'   => '100',
+						'total'      => '95',
+					),
+				),
+				'coupon_lines' => array( array( 'code' => 'created-percent' ) ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 201, $response->get_status() );
+		$this->assertEquals( 100, $data['line_items'][0]['subtotal'], 'The posted subtotal should stay the pre-discount price' );
+		$this->assertEquals( 90, $data['line_items'][0]['total'], 'The total should be recalculated from the posted subtotal, not from or on top of the posted total' );
+		$this->assertEquals( 10, $data['discount_total'] );
+	}
+
+	/**
+	 * @testdox Updating an order with explicitly posted line totals and a coupon keeps the posted subtotal as the pre-discount price.
+	 */
+	public function test_update_with_posted_line_totals_and_coupon_does_not_double_discount(): void {
+		WC_Helper_Coupon::create_coupon(
+			'updated-percent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+
+		$order   = $this->create_guest_order_with_product( 'update-coupon-customer@example.com' );
+		$item_id = key( $order->get_items() );
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'line_items'   => array(
+					array(
+						'id'       => $item_id,
+						'subtotal' => '100',
+						'total'    => '95',
+					),
+				),
+				'coupon_lines' => array( array( 'code' => 'updated-percent' ) ),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertEquals( 100, $data['line_items'][0]['subtotal'], 'The posted subtotal should stay the pre-discount price' );
+		$this->assertEquals( 90, $data['line_items'][0]['total'], 'The total should be recalculated from the posted subtotal, not from or on top of the posted total' );
+		$this->assertEquals( 10, $data['discount_total'] );
+	}
 }