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 );