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.
 	 *