Commit 47ce6f38cb9 for woocommerce

commit 47ce6f38cb944b4e97d0a304ac5a94a8ab07f2c0
Author: Faisal Ahammad <faisalahammad24@gmail.com>
Date:   Fri Oct 23 22:25:26 2026 +0600

    Fix coupons not applying to products added later in the order editor (#69413)

    * Apply existing coupons to products added later in the admin order editor

    When a coupon is applied to an order before any product is added, the
    coupon discount stays at 0 after products are added through the order
    editor. Re-apply the order coupons after new items are added so the
    discount and totals include the new items.

    Closes #32596

    * Use the editor tax location when re-applying coupons to added items

    recalculate_coupons() recalculates taxes from the saved order address.
    Since #69518, added products use the address typed in the editor, so
    calculate taxes again with that address when the editor sends a country.

    - Recalculate taxes with the editor tax location after recalculate_coupons().
    - Add a test for a new order with an unsaved NL address and a coupon.

    Addresses PR feedback.

    Refs #69413

    * Keep hand edited line totals and tax inclusive discounts when re-applying coupons

    When products are added in the order editor, coupons are applied again.
    Line totals edited by hand on existing lines are now kept, and with prices
    that include tax the discount uses the tax of the address typed in the editor.

    * Re-apply coupons to added items with a single recalculation

    Remove the hand edit pass from maybe_add_order_item(). When the order
    has coupons, adding a product now runs one recalculate_coupons() call
    inside the editor tax location filter. This matches Apply coupon and
    Remove coupon, which also reset line totals to their subtotals.

    Drop test_add_item_after_coupon_keeps_edited_line_totals.

    Addresses PR feedback. Refs #69413

diff --git a/plugins/woocommerce/changelog/fix-32596-recalc-coupons-on-add-item b/plugins/woocommerce/changelog/fix-32596-recalc-coupons-on-add-item
new file mode 100644
index 00000000000..35db499593d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-32596-recalc-coupons-on-add-item
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply existing coupons to products added later in the admin order editor, so the discount and order total are correct.
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index 17db983ccaf..5d22cf610de 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -1319,6 +1319,30 @@ class WC_AJAX {

 			$order = wc_get_order( $order_id );

+			// Coupons already on the order were applied before these items existed, so apply them again to include the new items.
+			if ( ! empty( $added_items ) && $order instanceof WC_Order && $order->get_items( 'coupon' ) ) {
+				// recalculate_coupons() uses the saved address for discounts and taxes, so give it the address typed in the editor without saving it.
+				$use_editor_tax_location = null;
+				if ( ! empty( $tax_location['country'] ) ) {
+					$editor_tax_location     = $order->get_taxable_location( $tax_location );
+					$use_editor_tax_location = function ( $location, $location_order ) use ( $order, $editor_tax_location ) {
+						return $location_order->get_id() === $order->get_id() ? $editor_tax_location : $location;
+					};
+					add_filter( 'woocommerce_order_get_tax_location', $use_editor_tax_location, PHP_INT_MAX, 2 );
+				}
+
+				// When prices include tax, the discount is taken from the price with tax, so the new items need their tax first.
+				if ( $order->get_prices_include_tax() ) {
+					$order->calculate_taxes();
+				}
+
+				$order->recalculate_coupons();
+
+				if ( $use_editor_tax_location ) {
+					remove_filter( 'woocommerce_order_get_tax_location', $use_editor_tax_location, PHP_INT_MAX );
+				}
+			}
+
 			// Get HTML to return.
 			ob_start();
 			include __DIR__ . '/admin/meta-boxes/views/html-order-items.php';
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 b818b345a22..5d6a4b427a3 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -248,6 +248,207 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		}
 	}

+	/**
+	 * Call the private `maybe_add_order_item` method.
+	 *
+	 * @param int   $order_id     Order ID.
+	 * @param array $items_to_add Items to add.
+	 * @param array $tax_location Tax location entered in the order editor.
+	 */
+	private function add_items_to_order( int $order_id, array $items_to_add, array $tax_location = array() ): void {
+		$maybe_add_order_item_func = function () use ( $order_id, $items_to_add, $tax_location ) {
+			return static::maybe_add_order_item( $order_id, '', $items_to_add, $tax_location );
+		};
+		$maybe_add_order_item_func->call( new WC_AJAX() );
+	}
+
+	/**
+	 * @testdox Should apply a coupon to products added after the coupon was applied to an empty order.
+	 */
+	public function test_add_item_after_coupon_on_empty_order_applies_discount(): void {
+		WC_Helper_Coupon::create_coupon(
+			'tenpercent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		$order   = wc_create_order();
+
+		$result = $order->apply_coupon( 'tenpercent' );
+		$this->assertTrue( $result, 'The coupon should apply to the empty order' );
+
+		$this->add_items_to_order(
+			$order->get_id(),
+			array(
+				array(
+					'id'  => $product->get_id(),
+					'qty' => 2,
+				),
+			)
+		);
+
+		$order       = wc_get_order( $order->get_id() );
+		$coupon_item = current( $order->get_items( 'coupon' ) );
+		$line_item   = current( $order->get_items() );
+
+		$this->assertEquals( 2, $coupon_item->get_discount(), 'The coupon discount should include the added product' );
+		$this->assertEquals( 18, $line_item->get_total(), 'The added line item should be discounted' );
+		$this->assertEquals( 18, $order->get_total(), 'The order total should include the coupon discount' );
+	}
+
+	/**
+	 * @testdox Should apply an existing coupon to a product added to an order that already has items.
+	 */
+	public function test_add_item_after_coupon_on_order_with_items_applies_discount(): void {
+		WC_Helper_Coupon::create_coupon(
+			'tenpercent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+		$product_a = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		$product_b = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		$order     = wc_create_order();
+		$order->add_product( $product_a, 1 );
+		$order->calculate_totals();
+		$order->apply_coupon( 'tenpercent' );
+
+		$this->add_items_to_order(
+			$order->get_id(),
+			array(
+				array(
+					'id'  => $product_b->get_id(),
+					'qty' => 1,
+				),
+			)
+		);
+
+		$order       = wc_get_order( $order->get_id() );
+		$coupon_item = current( $order->get_items( 'coupon' ) );
+
+		foreach ( $order->get_items() as $line_item ) {
+			$this->assertEquals( 9, $line_item->get_total(), 'Every line item should be discounted' );
+		}
+		$this->assertEquals( 2, $coupon_item->get_discount(), 'The coupon discount should include both products' );
+		$this->assertEquals( 18, $order->get_total(), 'The order total should include the coupon discount' );
+	}
+
+	/**
+	 * @testdox Should use the editor tax location when coupons are applied again to an added product.
+	 */
+	public function test_add_item_after_coupon_uses_editor_tax_location(): void {
+		$order     = $this->add_item_to_coupon_order_with_editor_tax_location( false );
+		$line_item = current( $order->get_items() );
+
+		$this->assertEquals( 21.6, $line_item->get_total(), 'The added line item should be discounted' );
+		$this->assertEqualsWithDelta( 1.94, (float) $line_item->get_total_tax(), 0.01, 'The line tax should use the editor country rate' );
+		$this->assertEqualsWithDelta( 23.54, (float) $order->get_total(), 0.01, 'The order total should use the editor country tax' );
+		$this->assertSame( '', $order->get_billing_country(), 'The unsaved billing country must not be persisted' );
+	}
+
+	/**
+	 * @testdox Should use the editor tax location for the coupon discount when prices include tax.
+	 */
+	public function test_add_item_after_coupon_uses_editor_tax_location_with_prices_including_tax(): void {
+		$order     = $this->add_item_to_coupon_order_with_editor_tax_location( true );
+		$line_item = current( $order->get_items() );
+
+		$this->assertEqualsWithDelta( 22.21, (float) $order->get_total(), 0.01, 'The order total should be 10% off the price with the editor country tax' );
+		$this->assertEqualsWithDelta( 0.09, (float) $line_item->get_total_tax() / (float) $line_item->get_total(), 0.001, 'The line tax should use the editor country rate' );
+		$this->assertSame( '', $order->get_billing_country(), 'The unsaved billing country must not be persisted' );
+	}
+
+	/**
+	 * Create an order with a 10% coupon in a store based in BE (6% tax), then add a product priced 24 with NL (9% tax) typed in the editor.
+	 *
+	 * @param bool $prices_include_tax Whether prices are entered with tax.
+	 * @return WC_Order The order loaded again after the product was added.
+	 */
+	private function add_item_to_coupon_order_with_editor_tax_location( bool $prices_include_tax ): WC_Order {
+		update_option( 'woocommerce_calc_taxes', 'yes' );
+		update_option( 'woocommerce_prices_include_tax', $prices_include_tax ? 'yes' : 'no' );
+		update_option( 'woocommerce_default_country', 'BE' );
+		update_option( 'woocommerce_tax_based_on', 'billing' );
+
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'BE',
+				'tax_rate'          => '6.0000',
+				'tax_rate_name'     => 'Belgian VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+		WC_Tax::_insert_tax_rate(
+			array(
+				'tax_rate_country'  => 'NL',
+				'tax_rate'          => '9.0000',
+				'tax_rate_name'     => 'Dutch VAT',
+				'tax_rate_priority' => 1,
+				'tax_rate_class'    => '',
+			)
+		);
+
+		WC_Helper_Coupon::create_coupon(
+			'tenpercent',
+			array(
+				'discount_type' => 'percent',
+				'coupon_amount' => '10',
+			)
+		);
+		$product = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 24 ) );
+		$order   = wc_create_order();
+		$order->apply_coupon( 'tenpercent' );
+
+		$this->add_items_to_order(
+			$order->get_id(),
+			array(
+				array(
+					'id'  => $product->get_id(),
+					'qty' => 1,
+				),
+			),
+			array(
+				'country'  => 'NL',
+				'state'    => '',
+				'postcode' => '',
+				'city'     => '',
+			)
+		);
+
+		return wc_get_order( $order->get_id() );
+	}
+
+	/**
+	 * @testdox Should keep edited line totals when adding a product to an order without coupons.
+	 */
+	public function test_add_item_to_order_without_coupons_keeps_line_totals(): void {
+		$product_a = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		$product_b = WC_Helper_Product::create_simple_product( true, array( 'regular_price' => 10 ) );
+		$order     = wc_create_order();
+		$item_id   = $order->add_product( $product_a, 1 );
+		$item      = $order->get_item( $item_id );
+		$item->set_total( 5 );
+		$item->save();
+
+		$this->add_items_to_order(
+			$order->get_id(),
+			array(
+				array(
+					'id'  => $product_b->get_id(),
+					'qty' => 1,
+				),
+			)
+		);
+
+		$order = wc_get_order( $order->get_id() );
+
+		$this->assertEquals( 5, $order->get_item( $item_id )->get_total(), 'The edited line total should not be reset' );
+	}
+
 	/**
 	 * Creating an API Key with too long of a description should report failure.
 	 */