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'] );
+ }
}