Commit 592465e4f92 for woocommerce

commit 592465e4f9257b9f0254c8c1528d92989fd61717
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date:   Wed Oct 21 09:01:28 2026 +0200

    Keep coupon usage counts in step with order changes (#69632)

    * Only record coupon usage once the order has coupons

    Orders were marked as having their coupon usage recorded on their
    first valid status change, even when they had no coupons yet. Coupons
    added later without apply_coupon() – through the REST API, importers
    or extensions – were then never counted, including when the customer
    paid the order from My Account.

    The recorded flag is now set only when the order has coupons.

    * Lower coupon usage on removal only when the order counted it

    remove_coupon() always lowered the coupon's usage count, even when the
    order had never counted it – for example an unpaid checkout order or a
    coupon added without apply_coupon(). That took a use away from another
    order. It also used only the customer ID, so a guest's billing email
    stayed in the coupon's used-by list and kept counting against the
    per-customer limit.

    Usage is now lowered only for orders that recorded it, using the same
    customer ID or billing email that was used when counting. Once the
    last coupon is gone the recorded flag is cleared, so a coupon added
    later is counted again.

    * Add changelog entry for coupon usage count fixes

    * Use OrderStatus::AUTO_DRAFT in the coupon usage count test

    * Sync the coupon usage flag only when remove_coupon() releases usage

    ---------

    Co-authored-by: Raluca Stan <ralucastn@gmail.com>

diff --git a/plugins/woocommerce/changelog/fix-WOOPLUG-477-coupon-usage-counts-late-coupons b/plugins/woocommerce/changelog/fix-WOOPLUG-477-coupon-usage-counts-late-coupons
new file mode 100644
index 00000000000..9c6b4f80f73
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOOPLUG-477-coupon-usage-counts-late-coupons
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep coupon usage counts accurate when coupons are added to an order after it was created, or removed from an order.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index acc87665401..40b5dcd1428 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -24,6 +24,7 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\ArrayUtil;
 use Automattic\WooCommerce\Utilities\NumberUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\Utilities\StringUtil;

 defined( 'ABSPATH' ) || exit;

@@ -1886,13 +1887,37 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
 	 */
 	public function remove_coupon( $code ) {
 		$coupons = $this->get_items( 'coupon' );
+		/**
+		 * Data store wrapper.
+		 *
+		 * @var WC_Data_Store $order_data_store
+		 */
+		$order_data_store = $this->get_data_store();
+		// @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying order data store instance.)
+		$has_recorded = $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this );
+
+		$used_by = $this->get_user_id();
+		if ( ! $used_by && is_callable( array( $this, 'get_billing_email' ) ) ) {
+			$used_by = $this->get_billing_email();
+		}

 		// Remove the coupon line.
 		foreach ( $coupons as $item_id => $coupon ) {
 			if ( wc_is_same_coupon( $coupon->get_code(), $code ) ) {
 				$this->remove_item( $item_id );
 				$coupon_object = new WC_Coupon( $code );
-				$coupon_object->decrease_usage_count( $this->get_user_id() );
+
+				if ( $has_recorded ) {
+					// Mirror the stored flag into this instance so clearing it below is tracked as a change and saved.
+					$this->set_recorded_coupon_usage_counts( true );
+					$coupon_object->decrease_usage_count( $used_by );
+
+					$remaining_codes = array_filter( $this->get_coupon_codes(), fn( $remaining_code ) => ! StringUtil::is_null_or_whitespace( $remaining_code ) );
+					if ( ! $remaining_codes ) {
+						$this->set_recorded_coupon_usage_counts( false );
+					}
+				}
+
 				$this->recalculate_coupons();

 				/**
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 1741d3473b6..d290cfbe596 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -1037,6 +1037,7 @@ function wc_update_coupon_usage_counts( $order_id ) {
 		return;
 	}

+	$coupon_codes     = array_filter( $order->get_coupon_codes(), fn( $code ) => ! StringUtil::is_null_or_whitespace( $code ) );
 	$has_recorded     = $order->get_data_store()->get_recorded_coupon_usage_counts( $order );
 	$invalid_statuses = array( OrderStatus::CANCELLED, OrderStatus::FAILED, OrderStatus::TRASH );

@@ -1052,25 +1053,23 @@ function wc_update_coupon_usage_counts( $order_id ) {
 		$invalid_statuses
 	);

-	if ( $order->has_status( $invalid_statuses ) && $has_recorded ) {
+	$is_invalid = $order->has_status( $invalid_statuses );
+
+	if ( $is_invalid && $has_recorded ) {
 		$action = 'reduce';
 		$order->get_data_store()->set_recorded_coupon_usage_counts( $order, false );
-	} elseif ( ! $order->has_status( $invalid_statuses ) && ! $has_recorded ) {
+	} elseif ( ! $is_invalid && $coupon_codes && ! $has_recorded ) {
 		$action = 'increase';
 		$order->get_data_store()->set_recorded_coupon_usage_counts( $order, true );
-	} elseif ( $order->has_status( $invalid_statuses ) ) {
+	} elseif ( $is_invalid ) {
 		wc_release_coupons_for_order( $order );
 		return;
 	} else {
 		return;
 	}

-	if ( count( $order->get_coupon_codes() ) > 0 ) {
-		foreach ( $order->get_coupon_codes() as $code ) {
-			if ( StringUtil::is_null_or_whitespace( $code ) ) {
-				continue;
-			}
-
+	if ( count( $coupon_codes ) > 0 ) {
+		foreach ( $coupon_codes as $code ) {
 			$coupon  = new WC_Coupon( $code );
 			$used_by = $order->get_user_id();

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 962c488bc20..5b771721dbc 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
@@ -748,6 +748,114 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Removing a coupon from an order that never recorded usage leaves the coupon usage count alone.
+	 */
+	public function test_remove_coupon_keeps_usage_count_when_order_did_not_record_it() {
+		$coupon_code = 'remove_unrecorded_test';
+		WC_Helper_Coupon::create_coupon( $coupon_code );
+
+		$counted_order = WC_Helper_Order::create_order();
+		$counted_order->set_status( OrderStatus::PENDING );
+		$counted_order->save();
+		$counted_order->apply_coupon( $coupon_code );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$item = new WC_Order_Item_Coupon();
+		$item->set_code( $coupon_code );
+		$order->add_item( $item );
+		$order->save();
+		$this->assertFalse( $order->get_data_store()->get_recorded_coupon_usage_counts( $order ) );
+
+		$this->assertTrue( $order->remove_coupon( $coupon_code ) );
+
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+	}
+
+	/**
+	 * @testdox Removing the last coupon from a recorded order releases its usage, clears the flag, and lets a later coupon be counted.
+	 */
+	public function test_remove_coupon_from_recorded_order_releases_usage_and_clears_flag() {
+		$coupon_code = 'remove_recorded_test';
+		WC_Helper_Coupon::create_coupon( $coupon_code );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->apply_coupon( $coupon_code );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+		$order = wc_get_order( $order->get_id() );
+		$this->assertTrue( $order->get_data_store()->get_recorded_coupon_usage_counts( $order ) );
+
+		$this->assertTrue( $order->remove_coupon( $coupon_code ) );
+
+		$reloaded = wc_get_order( $order->get_id() );
+		$this->assertCount( 0, $reloaded->get_items( 'coupon' ) );
+		$this->assertFalse( $reloaded->get_data_store()->get_recorded_coupon_usage_counts( $reloaded ) );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+
+		$item = new WC_Order_Item_Coupon();
+		$item->set_code( $coupon_code );
+		$reloaded->add_item( $item );
+		$reloaded->save();
+		$reloaded->set_status( OrderStatus::ON_HOLD );
+		$reloaded->save();
+
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+	}
+
+	/**
+	 * @testdox Removing a coupon right after applying it on the same order instance clears the stored flag.
+	 */
+	public function test_remove_coupon_after_apply_on_same_instance_clears_stored_flag() {
+		$coupon_code = 'remove_same_instance_test';
+		WC_Helper_Coupon::create_coupon( $coupon_code );
+
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->apply_coupon( $coupon_code );
+		$this->assertTrue( $order->remove_coupon( $coupon_code ) );
+
+		$reloaded = wc_get_order( $order->get_id() );
+		$this->assertFalse( $reloaded->get_data_store()->get_recorded_coupon_usage_counts( $reloaded ) );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+
+		$item = new WC_Order_Item_Coupon();
+		$item->set_code( $coupon_code );
+		$reloaded->add_item( $item );
+		$reloaded->save();
+		$reloaded->set_status( OrderStatus::ON_HOLD );
+		$reloaded->save();
+
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+	}
+
+	/**
+	 * @testdox Removing a coupon from a recorded guest order removes the billing email that was recorded as a user.
+	 */
+	public function test_remove_coupon_from_recorded_guest_order_removes_billing_email() {
+		$coupon_code = 'remove_guest_test';
+		$email       = 'guest-remove-coupon@example.com';
+		WC_Helper_Coupon::create_coupon( $coupon_code );
+
+		$order = WC_Helper_Order::create_order( 0 );
+		$order->set_billing_email( $email );
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		$order->apply_coupon( $coupon_code );
+
+		$this->assertContains( $email, ( new WC_Coupon( $coupon_code ) )->get_used_by() );
+
+		$order->remove_coupon( $coupon_code );
+
+		$this->assertNotContains( $email, ( new WC_Coupon( $coupon_code ) )->get_used_by() );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
+	}
+
 	/**
 	 * Create a pending order with one $100 product whose line total was manually edited to $50.
 	 *
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 21973db55a2..004e1d37182 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
@@ -7,6 +7,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
 use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
 use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\CouponHelper;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
 use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
 use Automattic\WooCommerce\Tests\Helpers\MetaDataAssertionTrait;
@@ -2418,4 +2419,39 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 			),
 		);
 	}
+
+	/**
+	 * Sending the coupons an order already counts must not change their usage counts.
+	 */
+	public function test_update_order_with_same_coupons_keeps_usage_counts() {
+		wp_set_current_user( $this->user );
+
+		$order = OrderHelper::create_order();
+		$order->set_status( OrderStatus::PENDING );
+		$order->save();
+		CouponHelper::create_coupon( 'usage-coupon-a' );
+		CouponHelper::create_coupon( 'usage-coupon-b' );
+		$order->apply_coupon( 'usage-coupon-a' );
+		$order->apply_coupon( 'usage-coupon-b' );
+		$this->assertEquals( 1, ( new WC_Coupon( 'usage-coupon-a' ) )->get_usage_count() );
+		$this->assertEquals( 1, ( new WC_Coupon( 'usage-coupon-b' ) )->get_usage_count() );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+		$request->set_body_params(
+			array(
+				'coupon_lines' => array(
+					array( 'code' => 'usage-coupon-a' ),
+					array( 'code' => 'usage-coupon-b' ),
+				),
+			)
+		);
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$order = wc_get_order( $order->get_id() );
+		$this->assertCount( 2, $order->get_items( 'coupon' ) );
+		$this->assertTrue( $order->get_data_store()->get_recorded_coupon_usage_counts( $order ) );
+		$this->assertEquals( 1, ( new WC_Coupon( 'usage-coupon-a' ) )->get_usage_count() );
+		$this->assertEquals( 1, ( new WC_Coupon( 'usage-coupon-b' ) )->get_usage_count() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index 5f0ee75078d..72ffb2df23a 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -248,6 +248,119 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
 		$this->assertSame( 0, ( new WC_Coupon( $coupon->get_code() ) )->get_usage_count() );
 	}

+	/**
+	 * Add a raw coupon item to an order and save it, bypassing apply_coupon().
+	 *
+	 * @param WC_Order $order Order.
+	 * @param string   $code  Coupon code.
+	 */
+	private function add_raw_coupon_item( WC_Order $order, string $code ): void {
+		$item = new WC_Order_Item_Coupon();
+		$item->set_code( $code );
+		$item->set_discount( 1 );
+		$order->add_item( $item );
+		$order->save();
+	}
+
+	/**
+	 * @testdox Coupon usage is not marked as recorded while the order has no coupons, so coupons added later are counted.
+	 */
+	public function test_wc_update_coupon_usage_counts_counts_coupons_added_after_first_status_change() {
+		$coupon = WC_Helper_Coupon::create_coupon( 'late-coupon' );
+		$order  = WC_Helper_Order::create_order();
+		$store  = $order->get_data_store();
+
+		$this->assertFalse( $store->get_recorded_coupon_usage_counts( $order ) );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->update_status( OrderStatus::ON_HOLD );
+		$this->assertFalse( $store->get_recorded_coupon_usage_counts( $order ) );
+
+		$this->add_raw_coupon_item( $order, $coupon->get_code() );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->update_status( OrderStatus::PROCESSING );
+		$this->assertTrue( $store->get_recorded_coupon_usage_counts( $order ) );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->update_status( OrderStatus::CANCELLED );
+		$this->assertEquals( 0, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->update_status( OrderStatus::PROCESSING );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->update_status( OrderStatus::PROCESSING );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+	}
+
+	/**
+	 * @testdox An order without coupons never gets the recorded flag through valid status changes.
+	 */
+	public function test_wc_update_coupon_usage_counts_does_not_flag_orders_without_coupons() {
+		$order = WC_Helper_Order::create_order();
+		$store = $order->get_data_store();
+
+		foreach ( array( OrderStatus::PENDING, OrderStatus::ON_HOLD, OrderStatus::PROCESSING, OrderStatus::COMPLETED ) as $status ) {
+			$order->update_status( $status );
+			$this->assertFalse( $store->get_recorded_coupon_usage_counts( $order ), "Flag set for status {$status}." );
+		}
+	}
+
+	/**
+	 * @testdox Removing coupon lines without lowering usage and re-adding them later does not count the coupon twice.
+	 */
+	public function test_wc_update_coupon_usage_counts_does_not_double_count_coupons_removed_and_re_added() {
+		$coupon = WC_Helper_Coupon::create_coupon( 'removed-coupon' );
+		$order  = WC_Helper_Order::create_order();
+		$store  = $order->get_data_store();
+
+		$this->add_raw_coupon_item( $order, $coupon->get_code() );
+		$order->update_status( OrderStatus::PROCESSING );
+		$this->assertTrue( $store->get_recorded_coupon_usage_counts( $order ) );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->remove_order_items( 'coupon' );
+		$order->save();
+		$order->update_status( OrderStatus::ON_HOLD );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$this->add_raw_coupon_item( $order, $coupon->get_code() );
+		$order->update_status( OrderStatus::PROCESSING );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+	}
+
+	/**
+	 * @testdox Pinning: a coupon applied to an auto-draft order is counted once when the order is later paid.
+	 */
+	public function test_wc_update_coupon_usage_counts_admin_applied_coupon_paid_later_counts_once() {
+		$coupon = WC_Helper_Coupon::create_coupon( 'admin-coupon' );
+		$order  = WC_Helper_Order::create_order();
+		$order->set_status( OrderStatus::AUTO_DRAFT );
+		$order->save();
+
+		$result = $order->apply_coupon_using_edited_totals( $coupon->get_code() );
+		$this->assertTrue( $result );
+
+		$order->update_status( OrderStatus::PENDING );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+
+		$order->payment_complete();
+		$order = wc_get_order( $order->get_id() );
+		$this->assertEquals( 1, ( new WC_Coupon( $coupon ) )->get_usage_count() );
+		$this->assertTrue( $order->get_data_store()->get_recorded_coupon_usage_counts( $order ) );
+
+		\Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore::sync_order_coupons( $order->get_id() );
+		global $wpdb;
+		$rows = $wpdb->get_var(
+			$wpdb->prepare(
+				"SELECT COUNT(*) FROM {$wpdb->prefix}wc_order_coupon_lookup WHERE order_id = %d AND coupon_id = %d",
+				$order->get_id(),
+				$coupon->get_id()
+			)
+		);
+		$this->assertEquals( 1, (int) $rows );
+	}
+
 	/**
 	 * Test getting total refunded for an item with and without refunds.
 	 */