Commit ac89017e8da for woocommerce
commit ac89017e8da21934dfa8b538894de8be3d7b86a8
Author: Ahmed Ismail <105542236+ahmedevsaad@users.noreply.github.com>
Date: Fri Nov 13 16:59:02 2026 +0200
Fix duplicate coupon lines when recalculating coupons on unsaved orders (#69726)
* Fix duplicate coupon line when recalculating unsaved coupons
Reuse an existing coupon item by code instead of by ID, so an unsaved coupon item is not added twice. Key order discount items by the order's item key, so unsaved line items no longer collide at ID 0 and are each discounted.
* Narrow the changelog entry to the duplicate coupon line fix
Trunk now handles unsaved line items in WC_Discounts, so this branch only
fixes the duplicated coupon line.
* Store the coupon definition on reused unsaved coupon lines
A coupon line staged with only a code now gets coupon_info when recalculate_coupons() reuses it, so a later recalculation can rebuild the coupon after it is deleted. Existing coupon_info or coupon_data is kept.
* Fix equals sign alignment in set_coupon_discount_amounts()
Move the inline comment above the aligned assignments so phpcs no longer
reports a MultipleStatementAlignment warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Mike Jolley <mike.jolley@me.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/69563-recalculate-coupons-unsaved-item b/plugins/woocommerce/changelog/69563-recalculate-coupons-unsaved-item
new file mode 100644
index 00000000000..2ae0ac46e7c
--- /dev/null
+++ b/plugins/woocommerce/changelog/69563-recalculate-coupons-unsaved-item
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Stop recalculating coupons on an unsaved order from adding a second line for the same coupon.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 2f81a10bb05..97c823cb526 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -2557,24 +2557,25 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( $coupon_discounts ) {
foreach ( $coupon_discounts as $coupon_code => $amount ) {
+ // Reuse a coupon line that has no ID yet, so recalculating doesn't add a second line.
$item_id = isset( $coupon_code_to_id[ $coupon_code ] ) ? $coupon_code_to_id[ $coupon_code ] : 0;
- $unsaved_item = $item_id || $this->writing_through ? null : $this->get_unsaved_coupon_item( $coupon_code );
+ $unsaved_item = $item_id ? null : $this->get_unsaved_coupon_item( $coupon_code );
$reuse_unsaved = null !== $unsaved_item;
- if ( $reuse_unsaved ) {
+ if ( $item_id ) {
+ $coupon_item = $this->get_item( $item_id, false );
+ } else {
$coupon_item = $unsaved_item;
- } elseif ( ! $item_id ) {
- $coupon_item = new WC_Order_Item_Coupon();
- $coupon_item->set_code( $coupon_code );
-
- // Add coupon data.
- $coupon_id = wc_get_coupon_id_by_code( $coupon_code );
- $coupon = new WC_Coupon( $coupon_id );
+ if ( ! $coupon_item ) {
+ $coupon_item = new WC_Order_Item_Coupon();
+ $coupon_item->set_code( $coupon_code );
+ }
- $coupon_info = $coupon->get_short_info();
- $coupon_item->add_meta_data( 'coupon_info', $coupon_info );
- } else {
- $coupon_item = $this->get_item( $item_id, false );
+ // Store the coupon definition so recalculation still works if the coupon is deleted later.
+ if ( ! $coupon_item->meta_exists( 'coupon_info' ) && ! $coupon_item->meta_exists( 'coupon_data' ) ) {
+ $coupon = new WC_Coupon( wc_get_coupon_id_by_code( $coupon_code ) );
+ $coupon_item->add_meta_data( 'coupon_info', $coupon->get_short_info() );
+ }
}
$discount_tax = 0;
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 41c23a694c1..b3c0c761e4e 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
@@ -108,6 +108,152 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Recalculating coupons reuses an unsaved coupon item instead of adding a duplicate.
+ */
+ public function test_recalculate_coupons_reuses_unsaved_coupon_item(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->add_product(
+ $product,
+ 1,
+ array(
+ 'subtotal' => 100,
+ 'total' => 100,
+ )
+ );
+ $order->save();
+
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'unsaved-coupon-item' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $coupon_item = new WC_Order_Item_Coupon();
+ $coupon_item->set_code( 'unsaved-coupon-item' );
+ $order->add_item( $coupon_item );
+
+ $order->recalculate_coupons();
+
+ $coupon_items = $order->get_items( 'coupon' );
+ $this->assertCount( 1, $coupon_items, 'The unsaved coupon item should not be duplicated.' );
+ $this->assertEquals( 10, reset( $coupon_items )->get_discount(), 'The coupon discount should be set on the existing item.' );
+ }
+
+ /**
+ * @testdox A reused unsaved coupon item keeps its coupon definition after the coupon is deleted.
+ */
+ public function test_recalculate_coupons_stores_coupon_info_on_reused_unsaved_item(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->add_product(
+ $product,
+ 1,
+ array(
+ 'subtotal' => 100,
+ 'total' => 100,
+ )
+ );
+ $order->save();
+
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'unsaved-coupon-info' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $coupon_item = new WC_Order_Item_Coupon();
+ $coupon_item->set_code( 'unsaved-coupon-info' );
+ $order->add_item( $coupon_item );
+ $order->recalculate_coupons();
+ $order->save();
+
+ $coupon->delete( true );
+ $line_item = current( $order->get_items() );
+ $line_item->set_subtotal( 200 );
+ $line_item->set_total( 200 );
+ $line_item->save();
+
+ $order = wc_get_order( $order->get_id() );
+ $order->recalculate_coupons();
+
+ $this->assertCount( 1, $order->get_items( 'coupon' ), 'The coupon item should not be duplicated.' );
+ $this->assertEquals( 20, $order->get_discount_total(), 'The deleted percentage coupon should still apply 10% from its stored definition.' );
+ }
+
+ /**
+ * @testdox Recalculating coupons keeps the coupon definition already stored on an unsaved coupon item.
+ */
+ public function test_recalculate_coupons_keeps_existing_coupon_info_on_unsaved_item(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->add_product(
+ $product,
+ 1,
+ array(
+ 'subtotal' => 100,
+ 'total' => 100,
+ )
+ );
+ $order->save();
+
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'unsaved-existing-info' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+ $original_info = $coupon->get_short_info();
+
+ $coupon->set_amount( 25 );
+ $coupon->save();
+
+ $coupon_item = new WC_Order_Item_Coupon();
+ $coupon_item->set_code( 'unsaved-existing-info' );
+ $coupon_item->add_meta_data( 'coupon_info', $original_info );
+ $order->add_item( $coupon_item );
+ $order->recalculate_coupons();
+
+ $coupon_item = current( $order->get_items( 'coupon' ) );
+ $this->assertCount( 1, $coupon_item->get_meta( 'coupon_info', false ), 'Only one coupon_info entry should be stored.' );
+ $this->assertSame( $original_info, $coupon_item->get_meta( 'coupon_info' ), 'The stored coupon definition should not be replaced.' );
+ }
+
+ /**
+ * @testdox Recalculating coupons discounts each unsaved line item without failing.
+ */
+ public function test_recalculate_coupons_with_unsaved_line_items(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'unsaved-line-items' );
+ $coupon->set_discount_type( 'percent' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $order = new WC_Order();
+ foreach ( array( 100, 50 ) as $total ) {
+ $line_item = new WC_Order_Item_Product();
+ $line_item->set_product( $product );
+ $line_item->set_quantity( 1 );
+ $line_item->set_subtotal( $total );
+ $line_item->set_total( $total );
+ $order->add_item( $line_item );
+ }
+ $coupon_item = new WC_Order_Item_Coupon();
+ $coupon_item->set_code( 'unsaved-line-items' );
+ $order->add_item( $coupon_item );
+
+ $order->recalculate_coupons();
+
+ $line_totals = array_map(
+ fn( $item ) => (float) $item->get_total(),
+ array_values( $order->get_items() )
+ );
+ $this->assertEqualsCanonicalizing( array( 90.0, 45.0 ), $line_totals, 'Each unsaved line item should get its own discount.' );
+ $this->assertCount( 1, $order->get_items( 'coupon' ), 'The coupon item should not be duplicated.' );
+ $this->assertEquals( 15, $order->get_discount_total() );
+ }
+
/**
* @testdox Order and refund defaults capture the store tax mode without pending changes.
* @dataProvider provide_order_tax_defaults