Commit dd36941c025 for woocommerce
commit dd36941c025ceeb814490b6bc422bed422047c63
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date: Tue Sep 15 12:07:27 2026 +0300
Fix BIS duplicate signup detection across guest and logged-in signups (#68354)
* fix: detect BIS guest signups when same email signs up logged in
* test: cover guest signup detected for logged-in user with different attributes
* test: assert guest BIS signups keep user_id 0 before checking the email fallback
* fix: scope BIS duplicate detection to active signups and attributes
Claude-Session: https://claude.ai/code/session_01J2qzzHpbsEyetwWZCkAtWJ
* fix: drop empty BIS status filters and cover pending cross-state signups
* chore: drop PHPStan baseline entries fixed by BIS signup rewrite
diff --git a/plugins/woocommerce/changelog/fix-bis-duplicate-signup-cross-state b/plugins/woocommerce/changelog/fix-bis-duplicate-signup-cross-state
new file mode 100644
index 00000000000..69529562c7f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-bis-duplicate-signup-cross-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix Back in Stock duplicate signup detection: match guest and logged-in signups with the same email, ignore cancelled and sent notifications, and keep signups for different variation attributes separate.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e015223bf5d..8e0598372ec 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -66499,18 +66499,6 @@ parameters:
count: 1
path: src/Internal/StockNotifications/Frontend/SignupService.php
- -
- message: '#^Method Automattic\\WooCommerce\\Internal\\StockNotifications\\Frontend\\SignupService\:\:is_already_signed_up\(\) should return Automattic\\WooCommerce\\Internal\\StockNotifications\\Notification\|null but returns Automattic\\WooCommerce\\Internal\\StockNotifications\\Notification\|true\.$#'
- identifier: return.type
- count: 1
- path: src/Internal/StockNotifications/Frontend/SignupService.php
-
- -
- message: '#^Parameter \#1 \$notification_id of static method Automattic\\WooCommerce\\Internal\\StockNotifications\\Factory\:\:get_notification\(\) expects int, float\|int\|string given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/StockNotifications/Frontend/SignupService.php
-
-
message: '#^Parameter \#1 \$object_or_string of function is_a expects object, int\|WP_Error given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
index d92c0c3c272..3a79724c604 100644
--- a/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/StockNotifications/StockNotificationsDataStore.php
@@ -441,12 +441,17 @@ CREATE TABLE $meta_table_name (
$where = array();
$where_values = array();
- if ( ! empty( $args['status'] ) ) {
- $statuses = array_values( array_filter( array_map( 'strval', (array) $args['status'] ) ) );
- if ( ! empty( $statuses ) ) {
- $where[] = 'status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')';
- $where_values = array_merge( $where_values, $statuses );
+ $statuses = array_filter(
+ array_map( 'strval', (array) $args['status'] ),
+ static function ( string $status ): bool {
+ return '' !== $status;
}
+ );
+ if ( ! empty( $statuses ) ) {
+ $where[] = 1 === count( $statuses )
+ ? 'status = %s'
+ : 'status IN (' . implode( ',', array_fill( 0, count( $statuses ), '%s' ) ) . ')';
+ $where_values = array_merge( $where_values, array_values( $statuses ) );
}
if ( ! empty( $args['product_id'] ) ) {
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
index 1949983ac32..0c93f797f1b 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Frontend/SignupService.php
@@ -240,7 +240,7 @@ class SignupService {
}
/**
- * Get the active notification for the request data.
+ * Get the active or pending notification for the request data.
*
* @param int $product_id The product ID.
* @param int $user_id The user ID.
@@ -260,48 +260,68 @@ class SignupService {
return null;
}
- $found = false;
+ // A customer may have signed up as a guest (user_id 0) before creating an account with the same
+ // email, or vice versa. Match on user ID first, then fall back to the email so both states are found.
+ $identities = array();
if ( ! empty( $user_id ) ) {
- $found = NotificationQuery::notification_exists_by_user_id( $product_id, $user_id );
- } else {
- $found = NotificationQuery::notification_exists_by_email( $product_id, $user_email );
- }
+ $identities[] = array( 'user_id' => $user_id );
+ }
+ if ( ! empty( $user_email ) ) {
+ $identities[] = array( 'user_email' => $user_email );
+ }
+
+ foreach ( $identities as $identity ) {
+ $notifications = NotificationQuery::get_notifications(
+ array_merge(
+ $identity,
+ array(
+ 'product_id' => $product_id,
+ 'status' => array( NotificationStatus::ACTIVE, NotificationStatus::PENDING ),
+ 'order_by' => array( 'id' => 'DESC' ),
+ 'return' => 'objects',
+ )
+ )
+ );
- if ( ! $found ) {
- return null;
+ foreach ( $notifications as $notification ) {
+ if ( $notification instanceof Notification && $this->matches_posted_attributes( $notification, $posted_attributes ) ) {
+ return $notification;
+ }
+ }
}
- $query_args = array( 'product_id' => $product_id );
- if ( ! empty( $user_id ) ) {
- $query_args['user_id'] = $user_id;
- } else {
- $query_args['user_email'] = $user_email;
- }
+ return null;
+ }
- $query_args['return'] = 'ids';
- $query_args['limit'] = 1;
- if ( ! empty( $posted_attributes ) ) {
- // Hint: We need to compare the posted attributes with the stored attributes to handle variations with "any" attributes.
- $query_args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- array(
- 'key' => 'posted_attributes',
- 'value' => maybe_serialize( $posted_attributes ),
- 'compare' => '=',
- ),
- );
+ /**
+ * Check whether a notification was signed up for with the posted attributes.
+ *
+ * A variation with "any" attributes can be signed up for more than once with different
+ * attribute values, so the stored attributes have to match the posted ones. An empty set
+ * of posted attributes matches any notification.
+ *
+ * @param Notification $notification The notification.
+ * @param array $posted_attributes The posted attributes.
+ * @return bool True if the notification matches the posted attributes.
+ */
+ private function matches_posted_attributes( Notification $notification, array $posted_attributes ): bool {
+
+ if ( empty( $posted_attributes ) ) {
+ return true;
}
- $ids = NotificationQuery::get_notifications( $query_args );
- if ( empty( $ids ) || ! is_numeric( $ids[0] ) ) {
- return null;
+ $stored_attributes = $notification->get_meta( 'posted_attributes' );
+ if ( ! is_array( $stored_attributes ) || count( $stored_attributes ) !== count( $posted_attributes ) ) {
+ return false;
}
- $notification = Factory::get_notification( $ids[0] );
- if ( ! $notification ) {
- return null;
+ foreach ( $posted_attributes as $key => $value ) {
+ if ( ! array_key_exists( $key, $stored_attributes ) || (string) $stored_attributes[ $key ] !== (string) $value ) {
+ return false;
+ }
}
- return $notification;
+ return true;
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
index 1c0cf626be5..9c6e5121065 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Frontend/SignupServiceTests.php
@@ -127,6 +127,142 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
$this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
}
+ /**
+ * @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user.
+ */
+ public function test_guest_signup_detected_for_logged_in_user_with_same_email() {
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $guest_result = $this->sut->signup( $product->get_id(), 0, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $guest_result->get_code() );
+ $this->assertSame( 0, $guest_result->get_notification()->get_user_id() );
+
+ $user_result = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_ALREADY_JOINED, $user_result->get_code() );
+ $this->assertSame( $guest_result->get_notification()->get_id(), $user_result->get_notification()->get_id() );
+
+ $found = $this->sut->is_already_signed_up( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertInstanceOf( Notification::class, $found );
+ $this->assertSame( $guest_result->get_notification()->get_id(), $found->get_id() );
+ }
+
+ /**
+ * @testdox Should detect an existing pending guest signup when the same email later signs up as a logged-in user with double opt-in enabled.
+ */
+ public function test_pending_guest_signup_detected_for_logged_in_user_with_double_opt_in() {
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'yes' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $guest_result = $this->sut->signup( $product->get_id(), 0, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS_DOUBLE_OPT_IN, $guest_result->get_code() );
+ $this->assertSame( NotificationStatus::PENDING, $guest_result->get_notification()->get_status() );
+
+ $user_result = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_ALREADY_JOINED_DOUBLE_OPT_IN, $user_result->get_code() );
+ $this->assertSame( $guest_result->get_notification()->get_id(), $user_result->get_notification()->get_id() );
+ }
+
+ /**
+ * @testdox Should detect an existing logged-in signup when the same email later signs up as a guest.
+ */
+ public function test_logged_in_signup_detected_for_guest_with_same_email() {
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $user_result = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $user_result->get_code() );
+
+ $guest_result = $this->sut->signup( $product->get_id(), 0, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_ALREADY_JOINED, $guest_result->get_code() );
+ $this->assertSame( $user_result->get_notification()->get_id(), $guest_result->get_notification()->get_id() );
+ }
+
+ /**
+ * @testdox Should detect an existing guest signup when the same email later signs up as a logged-in user with the same attributes.
+ */
+ public function test_guest_signup_detected_for_logged_in_user_with_same_attributes() {
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $guest_result = $this->sut->signup( $product->get_id(), 0, 'customer@example.com', array( 'attribute_pa_color' => 'blue' ) );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $guest_result->get_code() );
+ $this->assertSame( 0, $guest_result->get_notification()->get_user_id() );
+
+ $user_result = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com', array( 'attribute_pa_color' => 'blue' ) );
+ $this->assertSame( SignupService::SIGNUP_ALREADY_JOINED, $user_result->get_code() );
+ $this->assertSame( $guest_result->get_notification()->get_id(), $user_result->get_notification()->get_id() );
+ }
+
+ /**
+ * @testdox Should allow a second signup for the same variation with different posted attributes.
+ */
+ public function test_different_posted_attributes_are_not_a_duplicate() {
+ $this->disable_signup_rate_limiting();
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $guest_result = $this->sut->signup( $product->get_id(), 0, 'customer@example.com', array( 'attribute_pa_color' => 'blue' ) );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $guest_result->get_code() );
+ $this->assertSame( 0, $guest_result->get_notification()->get_user_id() );
+
+ $user_result = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com', array( 'attribute_pa_color' => 'red' ) );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $user_result->get_code() );
+ $this->assertNotSame( $guest_result->get_notification()->get_id(), $user_result->get_notification()->get_id() );
+
+ $found = $this->sut->is_already_signed_up( $product->get_id(), $user_id, 'customer@example.com', array( 'attribute_pa_color' => 'blue' ) );
+ $this->assertInstanceOf( Notification::class, $found );
+ $this->assertSame( $guest_result->get_notification()->get_id(), $found->get_id() );
+ }
+
+ /**
+ * @testdox Should not let a cancelled notification hide a later active one.
+ */
+ public function test_cancelled_notification_does_not_hide_active_one() {
+ $this->disable_signup_rate_limiting();
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $first = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' )->get_notification();
+ $first->set_status( NotificationStatus::CANCELLED );
+ $first->save();
+
+ $second = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_SUCCESS, $second->get_code() );
+ $this->assertNotSame( $first->get_id(), $second->get_notification()->get_id() );
+
+ // The cancelled notification is older, so it must not be the one the third signup finds.
+ $third = $this->sut->signup( $product->get_id(), $user_id, 'customer@example.com' );
+ $this->assertSame( SignupService::SIGNUP_ALREADY_JOINED, $third->get_code() );
+ $this->assertSame( $second->get_notification()->get_id(), $third->get_notification()->get_id() );
+ }
+
+ /**
+ * @testdox Should not treat a different email as a duplicate when the user ID has no signup.
+ */
+ public function test_no_false_duplicate_for_different_email() {
+ update_option( 'woocommerce_customer_stock_notifications_require_double_opt_in', 'no' );
+
+ $product = $this->create_out_of_stock_product();
+ $user_id = $this->factory->user->create( array( 'user_email' => 'customer@example.com' ) );
+
+ $this->sut->signup( $product->get_id(), 0, 'guest@example.com' );
+
+ $this->assertNull( $this->sut->is_already_signed_up( $product->get_id(), $user_id, 'customer@example.com' ) );
+ }
+
/**
* @testdox Should reject a second signup made within the rate limit window.
*/
@@ -297,6 +433,19 @@ class SignupServiceTests extends \WC_Unit_Test_Case {
$this->assertInstanceOf( Notification::class, $sut->is_already_signed_up( $product->get_id(), 0, 'guest@example.com' ), 'The notification should have been created' );
}
+ /**
+ * Switch the sign-up rate limiter off, for tests that legitimately create two sign-ups in a row.
+ */
+ private function disable_signup_rate_limiting(): void {
+ add_filter(
+ 'woocommerce_customer_stock_notifications_signup_rate_limit_options',
+ static function ( $options ) {
+ $options['enabled'] = false;
+ return $options;
+ }
+ );
+ }
+
/**
* Create an out-of-stock simple product for signup.
*