Commit fee37c102c6 for woocommerce

commit fee37c102c6e00888436d1688d373b6d78fef348
Author: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com>
Date:   Mon Aug 10 14:04:23 2026 +0100

    Store API: enforce coupon global usage limit when paying for an existing order (#67543)

    Co-authored-by: Jorge Torres <jorge.torres@automattic.com>

diff --git a/plugins/woocommerce/changelog/fix-store-api-order-coupon-usage-limit b/plugins/woocommerce/changelog/fix-store-api-order-coupon-usage-limit
new file mode 100644
index 00000000000..d4a8e327d4f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-order-coupon-usage-limit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Store API: improve coupon handling for order payments.
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
index e718cb690ac..fa242908350 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php
@@ -241,6 +241,10 @@ class OrderController {
 		$validators    = array( 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' );
 		$coupon_errors = array();

+		if ( $use_order_data ) {
+			$validators[] = 'validate_coupon_global_usage_limit';
+		}
+
 		foreach ( $coupons as $coupon ) {
 			try {
 				array_walk(
@@ -260,8 +264,18 @@ class OrderController {
 			if ( $use_order_data ) {
 				$error_code = 'woocommerce_rest_order_coupon_errors';

-				foreach ( $coupon_errors as $coupon_code => $message ) {
-					$order->remove_coupon( $coupon_code );
+				if ( $order->get_recorded_coupon_usage_counts() ) {
+					foreach ( $coupon_errors as $coupon_code => $message ) {
+						$order->remove_coupon( $coupon_code );
+					}
+				} else {
+					// Remove directly. `remove_coupon()` would decrement `usage_count` this order never recorded.
+					foreach ( $order->get_items( 'coupon' ) as $item_id => $coupon_item ) {
+						if ( $coupon_item instanceof \WC_Order_Item_Coupon && isset( $coupon_errors[ $coupon_item->get_code() ] ) ) {
+							$order->remove_item( $item_id );
+						}
+					}
+					$order->recalculate_coupons();
 				}

 				// Recalculate totals.
@@ -599,6 +613,34 @@ class OrderController {
 		}
 	}

+	/**
+	 * Check the coupon's global usage limit against the order.
+	 *
+	 * Skipped once the order has recorded its own usage, so it is not counted against itself.
+	 *
+	 * @throws Exception Exception if the global usage limit has been reached.
+	 * @param \WC_Coupon $coupon Coupon object applied to the order.
+	 * @param \WC_Order  $order Order object.
+	 */
+	protected function validate_coupon_global_usage_limit( \WC_Coupon $coupon, \WC_Order $order ): void {
+		$usage_limit = $coupon->get_usage_limit();
+
+		if ( ! $usage_limit || $order->get_recorded_coupon_usage_counts() ) {
+			return;
+		}
+
+		// Include tentative holds, matching WC_Discounts::validate_coupon_usage_limit().
+		$data_store      = $coupon->get_data_store();
+		$tentative_usage = is_callable( array( $data_store, 'get_tentative_usage_count' ) )
+			? (int) $data_store->get_tentative_usage_count( $coupon->get_id() )
+			: 0;
+
+		if ( $coupon->get_usage_count() + $tentative_usage >= $usage_limit ) {
+			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
+			throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ) );
+		}
+	}
+
 	/**
 	 * Get user email from user id.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 0761670c70e..c2effc6a156 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -2033,6 +2033,74 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertEquals( $original_total, $stored_order->get_total() );
 	}

+	/**
+	 * @testdox Paying an existing order enforces the coupon's global usage limit.
+	 * @testWith ["route-global-limited", true, 409, false]
+	 *           ["route-global-ok", false, 200, true]
+	 *
+	 * @param string $code            Coupon code.
+	 * @param bool   $exhaust         Exhaust the coupon's global limit first.
+	 * @param int    $expected_status Expected HTTP status.
+	 * @param bool   $keeps_coupon    Whether the coupon should remain on the order.
+	 */
+	public function test_checkout_order_enforces_coupon_global_usage_limit( $code, $exhaust, $expected_status, $keeps_coupon ) {
+		$coupon = new \WC_Coupon();
+		$coupon->set_code( $code );
+		$coupon->set_amount( 2 );
+		$coupon->set_usage_limit( 1 );
+		$coupon->save();
+		if ( $exhaust ) {
+			$coupon->increase_usage_count();
+		}
+
+		$order = \WC_Helper_Order::create_order( 0 );
+		$item  = new \WC_Order_Item_Coupon();
+		$item->set_code( $coupon->get_code() );
+		$order->add_item( $item );
+		$order->set_recorded_coupon_usage_counts( false );
+		$order->save();
+
+		$address = array(
+			'first_name' => 'Test',
+			'last_name'  => 'User',
+			'company'    => '',
+			'address_1'  => '123 Test St',
+			'address_2'  => '',
+			'city'       => 'Test City',
+			'state'      => 'CA',
+			'postcode'   => '90210',
+			'country'    => 'US',
+			'phone'      => '555-32123',
+		);
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout/' . $order->get_id() );
+		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+		$request->set_query_params(
+			array(
+				'key'           => $order->get_order_key(),
+				'billing_email' => $order->get_billing_email(),
+			)
+		);
+		$request->set_body_params(
+			array(
+				'billing_address'  => array_merge( $address, array( 'email' => $order->get_billing_email() ) ),
+				'shipping_address' => $address,
+				'payment_method'   => WC_Gateway_BACS::ID,
+			)
+		);
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertEquals( $expected_status, $response->get_status(), wp_json_encode( $response->get_data() ) );
+
+		$codes = wc_get_order( $order->get_id() )->get_coupon_codes();
+		if ( $keeps_coupon ) {
+			$this->assertContains( $code, $codes );
+		} else {
+			$this->assertNotContains( $code, $codes );
+			$this->assertEquals( 'woocommerce_rest_order_coupon_errors', $response->get_data()['code'] );
+		}
+	}
+
 	/**
 	 * Helper method to register custom order status.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
index b3b5ddb20de..cae507f6c50 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/OrderControllerTests.php
@@ -199,6 +199,50 @@ class OrderControllerTests extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Existing-order validation keeps the coupon when the order already recorded its usage.
+	 */
+	public function test_validate_existing_order_before_payment_keeps_coupon_when_usage_recorded() {
+		$coupon = CouponHelper::create_coupon( 'recorded-coupon', 'publish', array( 'usage_limit' => 1 ) );
+		$coupon->increase_usage_count();
+
+		$order = WC_Helper_Order::create_order();
+		$this->set_shipping_address( $order );
+		$item = new \WC_Order_Item_Coupon();
+		$item->set_code( $coupon->get_code() );
+		$order->add_item( $item );
+		$order->set_recorded_coupon_usage_counts( true );
+		$order->save();
+
+		$this->assertNull( $this->sut->validate_existing_order_before_payment( $order ) );
+		$this->assertEquals( array( 'recorded-coupon' ), $order->get_coupon_codes() );
+	}
+
+	/**
+	 * @testdox Stripping an exhausted coupon from a draft does not change its usage count.
+	 */
+	public function test_validate_existing_order_before_payment_does_not_decrement_usage_count() {
+		$coupon = CouponHelper::create_coupon( 'draft-global', 'publish', array( 'usage_limit' => 1 ) );
+		$coupon->increase_usage_count();
+		$this->assertEquals( 1, ( new \WC_Coupon( 'draft-global' ) )->get_usage_count() );
+
+		$order = WC_Helper_Order::create_order();
+		$item  = new \WC_Order_Item_Coupon();
+		$item->set_code( $coupon->get_code() );
+		$order->add_item( $item );
+		$order->save();
+
+		try {
+			$this->sut->validate_existing_order_before_payment( $order );
+			$this->fail( 'Expected a RouteException for the exhausted coupon.' );
+		} catch ( RouteException $e ) {
+			$this->assertEquals( 409, $e->getCode() );
+		}
+
+		$this->assertEmpty( $order->get_coupon_codes() );
+		$this->assertEquals( 1, ( new \WC_Coupon( 'draft-global' ) )->get_usage_count(), 'usage_count must not be decremented for a draft that never recorded it' );
+	}
+
 	/**
 	 * test_validate_order_before_payment_invalid_email.
 	 */
@@ -450,6 +494,7 @@ class OrderControllerTests extends \WC_Unit_Test_Case {
 		$order->set_shipping_city( 'Test City' );
 		$order->set_shipping_state( 'CA' );
 		$order->set_shipping_postcode( '12345' );
+		$order->set_shipping_phone( '555-32123' );

 		foreach ( $override_data as $key => $value ) {
 			$order->{"set_shipping_$key"}( $value );