Commit 173ae31d46b for woocommerce
commit 173ae31d46b7eda7edc90a59607686a55238f40a
Author: Pavel Dohnal <pavel.dohnal@automattic.com>
Date: Mon Oct 26 07:59:54 2026 +0100
Release coupon usage under the identity it was counted under (#70055)
* Release coupon usage under the identity it was counted under
Coupon usage is recorded against the customer's user ID or, for
guests, their billing email. Releasing it on coupon removal, cancel,
failure or trash used the order's current identity. When the billing
email changed after the coupon was counted, the release removed the
used-by entry of whichever order had used that email, and left this
order's own entry behind.
The identity each coupon is counted under is now stored on the order
and used to release it. Orders counted before this change fall back to
the current identity, as before.
* Test coupon release when paying an order with a changed email
* Add changelog entry for coupon usage release fix
diff --git a/plugins/woocommerce/changelog/fix-WOO6-211-coupon-usage-release-identity b/plugins/woocommerce/changelog/fix-WOO6-211-coupon-usage-release-identity
new file mode 100644
index 00000000000..25f8bbafce1
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOO6-211-coupon-usage-release-identity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Release coupon usage for the customer it was counted for, even when the order's billing email changed afterwards.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 9de8b284543..955759ac60c 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -1781,6 +1781,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
// @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying order data store instance.)
if ( $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this ) ) {
$coupon->increase_usage_count( $used_by );
+ if ( $coupon->get_id() ) {
+ $this->save_coupon_usage_identities( array( $coupon->get_id() => $used_by ) + $this->get_coupon_usage_identities() );
+ }
}
wc_update_coupon_usage_counts( $this->get_id() );
@@ -1930,7 +1933,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
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 );
+
+ $identities = $this->get_coupon_usage_identities();
+ $coupon_object->decrease_usage_count( $identities[ $coupon_object->get_id() ] ?? $used_by );
+ unset( $identities[ $coupon_object->get_id() ] );
+ $this->save_coupon_usage_identities( $identities );
$remaining_codes = array_filter( $this->get_coupon_codes(), fn( $remaining_code ) => ! StringUtil::is_null_or_whitespace( $remaining_code ) );
if ( ! $remaining_codes ) {
@@ -1957,6 +1964,41 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return false;
}
+ /**
+ * Get the identity each coupon was counted under, keyed by coupon ID.
+ *
+ * Reads and writes go through a fresh order instance, like wc_update_coupon_usage_counts(), so this
+ * instance never holds a stale copy of the key.
+ *
+ * @return array
+ */
+ private function get_coupon_usage_identities() {
+ $order = wc_get_order( $this->get_id() );
+ $identities = $order ? $order->get_meta( '_coupon_usage_identities' ) : array();
+
+ return is_array( $identities ) ? $identities : array();
+ }
+
+ /**
+ * Store the identity each coupon was counted under, or clear it when there are none.
+ *
+ * @param array $identities Identities keyed by coupon ID.
+ * @return void
+ */
+ private function save_coupon_usage_identities( array $identities ) {
+ $order = wc_get_order( $this->get_id() );
+ if ( ! $order ) {
+ return;
+ }
+
+ if ( $identities ) {
+ $order->update_meta_data( '_coupon_usage_identities', $identities );
+ } else {
+ $order->delete_meta_data( '_coupon_usage_identities' );
+ }
+ $order->save_meta_data();
+ }
+
/**
* Apply all coupons in this order again to all line items.
* This method is public since WooCommerce 3.8.0.
diff --git a/plugins/woocommerce/includes/class-wc-privacy-erasers.php b/plugins/woocommerce/includes/class-wc-privacy-erasers.php
index 3b32b448df6..bd0f2eb8385 100644
--- a/plugins/woocommerce/includes/class-wc-privacy-erasers.php
+++ b/plugins/woocommerce/includes/class-wc-privacy-erasers.php
@@ -336,6 +336,10 @@ class WC_Privacy_Erasers {
}
}
+ $identities = $order->get_meta( '_coupon_usage_identities' );
+ if ( $identities && is_array( $identities ) ) {
+ $order->update_meta_data( '_coupon_usage_identities', array_fill_keys( array_keys( $identities ), '' ) );
+ }
$order->update_meta_data( '_anonymized', 'yes' );
$order->save();
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 3e0b5a6ace7..bcca2af89d5 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -1073,6 +1073,9 @@ function wc_update_coupon_usage_counts( $order_id ) {
}
if ( count( $coupon_codes ) > 0 ) {
+ $identities = $order->get_meta( '_coupon_usage_identities' );
+ $identities = is_array( $identities ) ? $identities : array();
+
foreach ( $coupon_codes as $code ) {
$coupon = new WC_Coupon( $code );
$used_by = $order->get_user_id();
@@ -1083,13 +1086,23 @@ function wc_update_coupon_usage_counts( $order_id ) {
switch ( $action ) {
case 'reduce':
- $coupon->decrease_usage_count( $used_by );
+ $coupon->decrease_usage_count( $identities[ $coupon->get_id() ] ?? $used_by );
break;
case 'increase':
$coupon->increase_usage_count( $used_by, $order );
+ if ( $coupon->get_id() ) {
+ $identities[ $coupon->get_id() ] = $used_by;
+ }
break;
}
}
+
+ if ( 'increase' === $action && $identities ) {
+ $order->update_meta_data( '_coupon_usage_identities', $identities );
+ } elseif ( 'reduce' === $action ) {
+ $order->delete_meta_data( '_coupon_usage_identities' );
+ }
+ $order->save_meta_data();
wc_release_coupons_for_order( $order );
}
}
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 70ffb4116b1..9d6991f68b6 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
@@ -859,6 +859,67 @@ class WC_Abstract_Order_Test extends WC_Unit_Test_Case {
$this->assertEquals( 0, ( new WC_Coupon( $coupon_code ) )->get_usage_count() );
}
+ /**
+ * @testdox Removing a coupon after the billing email changed releases the row this order added, not another order's row.
+ */
+ public function test_remove_coupon_releases_identity_recorded_before_billing_email_changed() {
+ $coupon_code = 'remove_changed_email_test';
+ WC_Helper_Coupon::create_coupon( $coupon_code );
+
+ $order_a = WC_Helper_Order::create_order( 0 );
+ $order_a->set_billing_email( 'reserve@example.com' );
+ $order_a->set_status( OrderStatus::PENDING );
+ $order_a->save();
+ $order_a->apply_coupon( $coupon_code );
+
+ $order_b = WC_Helper_Order::create_order( 0 );
+ $order_b->set_billing_email( 'buyer@example.com' );
+ $order_b->set_status( OrderStatus::PENDING );
+ $order_b->save();
+ $order_b->apply_coupon( $coupon_code );
+
+ $order_a = wc_get_order( $order_a->get_id() );
+ $order_a->set_billing_email( 'buyer@example.com' );
+ $order_a->save();
+
+ $this->assertTrue( $order_a->remove_coupon( $coupon_code ) );
+
+ $coupon = new WC_Coupon( $coupon_code );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+ $this->assertSame( '', (string) wc_get_order( $order_a->get_id() )->get_meta( '_coupon_usage_identities' ) );
+ }
+
+ /**
+ * @testdox Removing a coupon after the order moved to another customer releases the original customer row, not the new customer's row.
+ */
+ public function test_remove_coupon_after_order_moved_to_another_customer_releases_original_customer() {
+ $coupon_code = 'remove_moved_customer_test';
+ WC_Helper_Coupon::create_coupon( $coupon_code );
+ $customer_a = WC_Helper_Customer::create_customer( 'movedcustomera', 'hunter2', 'movedcustomera@example.com' );
+ $customer_b = WC_Helper_Customer::create_customer( 'movedcustomerb', 'hunter2', 'movedcustomerb@example.com' );
+
+ $order_a = WC_Helper_Order::create_order( $customer_a->get_id() );
+ $order_a->set_status( OrderStatus::PENDING );
+ $order_a->save();
+ $order_a->apply_coupon( $coupon_code );
+
+ $order_b = WC_Helper_Order::create_order( $customer_b->get_id() );
+ $order_b->set_status( OrderStatus::PENDING );
+ $order_b->save();
+ $order_b->apply_coupon( $coupon_code );
+
+ $order_a = wc_get_order( $order_a->get_id() );
+ $order_a->set_customer_id( $customer_b->get_id() );
+ $order_a->save();
+
+ $this->assertTrue( $order_a->remove_coupon( $coupon_code ) );
+
+ $coupon = new WC_Coupon( $coupon_code );
+ $this->assertSame( array( (string) $customer_b->get_id() ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->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/class-wc-privacy-test.php b/plugins/woocommerce/tests/php/includes/class-wc-privacy-test.php
index e45a95f1431..0ac50ca66fd 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-privacy-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-privacy-test.php
@@ -1,5 +1,7 @@
<?php
+use Automattic\WooCommerce\Enums\OrderStatus;
+
/**
* Tests relating to the WC_Privacy class.
*/
@@ -27,4 +29,57 @@ class WC_Privacy_Test extends WC_Unit_Test_Case {
$this->assertFalse( get_user_by( 'id', $customer->get_id() ), 'The deleted user account no longer exists.' );
$this->assertEquals( '0', get_post_field( 'post_author', $post_id ), 'Posts belonging to the removed user still exist, but do not have an author.' );
}
+
+ /**
+ * @testdox Erasing the personal data of an order blanks the stored coupon usage identities.
+ *
+ * @return void
+ */
+ public function test_remove_order_personal_data_blanks_coupon_usage_identities(): void {
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( 'guest@example.com' );
+ $order->update_meta_data( '_coupon_usage_identities', array( 123 => 'guest@example.com' ) );
+ $order->save();
+
+ WC_Privacy_Erasers::remove_order_personal_data( $order );
+
+ $identities = wc_get_order( $order->get_id() )->get_meta( '_coupon_usage_identities' );
+ $this->assertSame( array( 123 => '' ), $identities, 'The entries are kept with blank values.' );
+ $this->assertStringNotContainsString( 'guest@example.com', wp_json_encode( $identities ), 'The email is gone.' );
+ }
+
+ /**
+ * @testdox Cancelling an erased order lowers the coupon count without removing another order's row.
+ *
+ * @return void
+ */
+ public function test_cancel_after_erasure_keeps_other_orders_coupon_usage(): void {
+ WC_Helper_Coupon::create_coupon( 'erased_release' );
+
+ $order_a = WC_Helper_Order::create_order( 0 );
+ $order_a->set_billing_email( 'guest@example.com' );
+ $order_a->set_status( OrderStatus::PENDING );
+ $order_a->save();
+ $order_a->apply_coupon( 'erased_release' );
+
+ $order_b = WC_Helper_Order::create_order( 0 );
+ $order_b->set_billing_email( 'other@example.com' );
+ $order_b->set_status( OrderStatus::PENDING );
+ $order_b->save();
+ $order_b->apply_coupon( 'erased_release' );
+
+ $this->assertEquals( 2, ( new WC_Coupon( 'erased_release' ) )->get_usage_count(), 'Both orders are counted.' );
+
+ $order_a = wc_get_order( $order_a->get_id() );
+ WC_Privacy_Erasers::remove_order_personal_data( $order_a );
+
+ $order_a = wc_get_order( $order_a->get_id() );
+ $order_a->set_billing_email( 'other@example.com' );
+ $order_a->save();
+ $order_a->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'erased_release' );
+ $this->assertEquals( 1, $coupon->get_usage_count(), 'The count drops by one.' );
+ $this->assertContains( 'other@example.com', $coupon->get_used_by(), 'The other order keeps its row.' );
+ }
}
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 60ff29f43f6..1ed39c627ab 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -915,4 +915,227 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
$this->assertSame( OrderStatus::PENDING, $untouched->get_status() );
$this->assertFalse( UnpaidOrderTimeoutMarker::is_marked( $untouched ) );
}
+
+ /**
+ * Create a pending guest order with the given coupons applied and counted.
+ *
+ * @param string $email Billing email.
+ * @param string[] $coupon_codes Coupon codes.
+ * @return WC_Order
+ */
+ private function create_counted_guest_order( string $email, array $coupon_codes ): WC_Order {
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( $email );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ foreach ( $coupon_codes as $code ) {
+ $order->apply_coupon( $code );
+ }
+
+ return wc_get_order( $order->get_id() );
+ }
+
+ /**
+ * Create a pending order for a customer with the given coupon applied and counted.
+ *
+ * @param int $customer_id Customer user ID.
+ * @param string $coupon_code Coupon code.
+ * @return WC_Order
+ */
+ private function create_counted_customer_order( int $customer_id, string $coupon_code ): WC_Order {
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ $order->apply_coupon( $coupon_code );
+
+ return wc_get_order( $order->get_id() );
+ }
+
+ /**
+ * @testdox Cancelling a customer order after its billing email changed removes the customer row and leaves an email row alone.
+ */
+ public function test_cancel_customer_order_after_billing_email_changed_removes_customer_row() {
+ WC_Helper_Coupon::create_coupon( 'cancel_customer_email' );
+ $customer = WC_Helper_Customer::create_customer( 'cancelcustomer', 'hunter2', 'cancelcustomer@example.com' );
+
+ $order = $this->create_counted_customer_order( $customer->get_id(), 'cancel_customer_email' );
+ $this->create_counted_guest_order( 'shared@example.com', array( 'cancel_customer_email' ) );
+
+ $this->assertEqualsCanonicalizing( array( (string) $customer->get_id(), 'shared@example.com' ), ( new WC_Coupon( 'cancel_customer_email' ) )->get_used_by() );
+
+ $order->set_billing_email( 'shared@example.com' );
+ $order->save();
+ $order->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'cancel_customer_email' );
+ $this->assertSame( array( 'shared@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+ }
+
+ /**
+ * @testdox Reactivating a cancelled guest order after its billing email changed counts and later releases the new email.
+ */
+ public function test_reactivating_cancelled_guest_order_with_changed_email_counts_new_email() {
+ WC_Helper_Coupon::create_coupon( 'reactivate_changed_email' );
+
+ $this->create_counted_guest_order( 'first@example.com', array( 'reactivate_changed_email' ) );
+ $order = $this->create_counted_guest_order( 'first@example.com', array( 'reactivate_changed_email' ) );
+
+ $order->update_status( OrderStatus::CANCELLED );
+ $coupon = new WC_Coupon( 'reactivate_changed_email' );
+ $this->assertSame( array( 'first@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+
+ $order = wc_get_order( $order->get_id() );
+ $order->set_billing_email( 'second@example.com' );
+ $order->save();
+ $order->update_status( OrderStatus::PROCESSING );
+
+ $coupon = new WC_Coupon( 'reactivate_changed_email' );
+ $this->assertEqualsCanonicalizing( array( 'first@example.com', 'second@example.com' ), $coupon->get_used_by() );
+ $this->assertEquals( 2, $coupon->get_usage_count() );
+ $this->assertSame( array( $coupon->get_id() => 'second@example.com' ), wc_get_order( $order->get_id() )->get_meta( '_coupon_usage_identities' ) );
+
+ wc_get_order( $order->get_id() )->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'reactivate_changed_email' );
+ $this->assertSame( array( 'first@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+ }
+
+ /**
+ * @testdox Cancelling an order after its billing email changed removes the row it added, not another order's row.
+ */
+ public function test_cancel_releases_identity_recorded_before_billing_email_changed() {
+ WC_Helper_Coupon::create_coupon( 'cancel_changed_email' );
+
+ $order_a = $this->create_counted_guest_order( 'reserve@example.com', array( 'cancel_changed_email' ) );
+ $this->create_counted_guest_order( 'buyer@example.com', array( 'cancel_changed_email' ) );
+
+ $order_a->set_billing_email( 'buyer@example.com' );
+ $order_a->save();
+ $order_a->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'cancel_changed_email' );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+ $this->assertSame( '', (string) wc_get_order( $order_a->get_id() )->get_meta( '_coupon_usage_identities' ) );
+ }
+
+ /**
+ * @testdox Cancelling an order releases each coupon under the identity it was counted with.
+ */
+ public function test_cancel_releases_each_coupon_under_its_own_identity() {
+ WC_Helper_Coupon::create_coupon( 'cancel_first' );
+ WC_Helper_Coupon::create_coupon( 'cancel_second' );
+
+ $order_a = $this->create_counted_guest_order( 'reserve@example.com', array( 'cancel_first' ) );
+ $this->create_counted_guest_order( 'buyer@example.com', array( 'cancel_first' ) );
+
+ $order_a->set_billing_email( 'buyer@example.com' );
+ $order_a->save();
+ $order_a->apply_coupon( 'cancel_second' );
+
+ $this->assertSame( array( 'buyer@example.com' ), array_values( ( new WC_Coupon( 'cancel_second' ) )->get_used_by() ) );
+
+ wc_get_order( $order_a->get_id() )->update_status( OrderStatus::CANCELLED );
+
+ $first = new WC_Coupon( 'cancel_first' );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $first->get_used_by() ) );
+ $this->assertEquals( 1, $first->get_usage_count() );
+
+ $second = new WC_Coupon( 'cancel_second' );
+ $this->assertSame( array(), array_values( $second->get_used_by() ) );
+ $this->assertEquals( 0, $second->get_usage_count() );
+ }
+
+ /**
+ * @testdox Cancelling an order releases a coupon applied to an already counted order under the identity it was counted with.
+ */
+ public function test_cancel_releases_coupon_applied_to_counted_order_under_its_own_identity() {
+ WC_Helper_Coupon::create_coupon( 'cancel_applied_first' );
+ WC_Helper_Coupon::create_coupon( 'cancel_applied_second' );
+
+ $order_a = $this->create_counted_guest_order( 'reserve@example.com', array( 'cancel_applied_first' ) );
+ $order_a->apply_coupon( 'cancel_applied_second' );
+ $this->create_counted_guest_order( 'buyer@example.com', array( 'cancel_applied_second' ) );
+
+ $order_a = wc_get_order( $order_a->get_id() );
+ $order_a->set_billing_email( 'buyer@example.com' );
+ $order_a->save();
+ $order_a->update_status( OrderStatus::CANCELLED );
+
+ $second = new WC_Coupon( 'cancel_applied_second' );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $second->get_used_by() ) );
+ $this->assertEquals( 1, $second->get_usage_count() );
+ }
+
+ /**
+ * @testdox Cancelling a guest order whose billing email did not change still removes its own row.
+ */
+ public function test_cancel_guest_order_removes_its_own_row() {
+ WC_Helper_Coupon::create_coupon( 'cancel_plain_guest' );
+
+ $order = $this->create_counted_guest_order( 'plain@example.com', array( 'cancel_plain_guest' ) );
+ $this->assertContains( 'plain@example.com', ( new WC_Coupon( 'cancel_plain_guest' ) )->get_used_by() );
+
+ $order->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'cancel_plain_guest' );
+ $this->assertSame( array(), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 0, $coupon->get_usage_count() );
+ }
+
+ /**
+ * @testdox Applying two coupons on one order instance keeps a single identity row holding both, and cancelling releases both.
+ */
+ public function test_two_coupons_applied_on_one_instance_keep_one_identity_row() {
+ WC_Helper_Coupon::create_coupon( 'identity_coupon_a' );
+ WC_Helper_Coupon::create_coupon( 'identity_coupon_b' );
+
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( 'reserve@example.com' );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ $order->apply_coupon( 'identity_coupon_a' );
+ $order->apply_coupon( 'identity_coupon_b' );
+
+ $coupon_a = new WC_Coupon( 'identity_coupon_a' );
+ $coupon_b = new WC_Coupon( 'identity_coupon_b' );
+
+ $rows = array_values( wc_get_order( $order->get_id() )->get_meta( '_coupon_usage_identities', false ) );
+ $this->assertCount( 1, $rows );
+ $this->assertEqualsCanonicalizing( array( $coupon_a->get_id(), $coupon_b->get_id() ), array_keys( $rows[0]->value ) );
+
+ $this->create_counted_guest_order( 'buyer@example.com', array( 'identity_coupon_a', 'identity_coupon_b' ) );
+
+ $cancelled = wc_get_order( $order->get_id() );
+ $cancelled->set_billing_email( 'buyer@example.com' );
+ $cancelled->save();
+ $cancelled->update_status( OrderStatus::CANCELLED );
+
+ foreach ( array( 'identity_coupon_a', 'identity_coupon_b' ) as $code ) {
+ $coupon = new WC_Coupon( $code );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $coupon->get_used_by() ), $code );
+ $this->assertEquals( 1, $coupon->get_usage_count(), $code );
+ }
+ }
+
+ /**
+ * @testdox Cancelling an order counted before identities were recorded falls back to its current email.
+ */
+ public function test_cancel_order_without_recorded_identities_falls_back_to_current_email() {
+ WC_Helper_Coupon::create_coupon( 'cancel_no_identities' );
+
+ $order = $this->create_counted_guest_order( 'legacy@example.com', array( 'cancel_no_identities' ) );
+ $order->delete_meta_data( '_coupon_usage_identities' );
+ $order->save();
+ $this->assertContains( 'legacy@example.com', ( new WC_Coupon( 'cancel_no_identities' ) )->get_used_by() );
+
+ wc_get_order( $order->get_id() )->update_status( OrderStatus::CANCELLED );
+
+ $coupon = new WC_Coupon( 'cancel_no_identities' );
+ $this->assertSame( array(), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 0, $coupon->get_usage_count() );
+ }
}
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 5c669586f35..169fc9fab3d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -2614,6 +2614,66 @@ class Checkout extends \WP_Test_REST_TestCase {
}
}
+ /**
+ * @testdox Rejecting payment because the new billing email cannot use the coupon releases the row this order added, not another order's row.
+ */
+ public function test_checkout_order_with_changed_email_releases_own_coupon_row() {
+ $coupon = new \WC_Coupon();
+ $coupon->set_code( 'route-changed-email' );
+ $coupon->set_amount( 2 );
+ $coupon->set_usage_limit_per_user( 1 );
+ $coupon->save();
+
+ $buyer_order = \WC_Helper_Order::create_order( 0 );
+ $buyer_order->set_billing_email( 'buyer@example.com' );
+ $buyer_order->set_status( OrderStatus::PENDING );
+ $buyer_order->save();
+ $buyer_order->apply_coupon( $coupon->get_code() );
+
+ $order = \WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( 'reserve@example.com' );
+ $order->set_status( OrderStatus::PENDING );
+ $order->save();
+ $order->apply_coupon( $coupon->get_code() );
+
+ $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' => 'reserve@example.com',
+ )
+ );
+ $request->set_body_params(
+ array(
+ 'billing_address' => array_merge( $address, array( 'email' => 'buyer@example.com' ) ),
+ 'shipping_address' => $address,
+ 'payment_method' => WC_Gateway_BACS::ID,
+ )
+ );
+
+ $response = rest_get_server()->dispatch( $request );
+
+ $this->assertSame( 409, $response->get_status(), print_r( $response->get_data(), true ) );
+ $this->assertSame( 'woocommerce_rest_order_coupon_errors', $response->get_data()['code'] );
+
+ $coupon = new \WC_Coupon( 'route-changed-email' );
+ $this->assertSame( array( 'buyer@example.com' ), array_values( $coupon->get_used_by() ) );
+ $this->assertEquals( 1, $coupon->get_usage_count() );
+ }
+
/**
* @testdox Checkout refuses to complete a cancelled unpaid non-zero order.
*/