Commit c97e0b80051 for woocommerce

commit c97e0b800517a4da1cc6c71e62875660836a89d5
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Wed Apr 8 10:14:47 2026 +0200

    Fix duplicate wc_customer_lookup rows on delayed account creation (#63989)

    * Fix duplicate wc_customer_lookup rows on delayed account creation

    When a guest places an order, a wc_customer_lookup row is created with
    user_id=NULL. If that guest later registers via the order confirmation
    page (delayed account creation), update_registered_customer() would
    insert a new row instead of updating the existing one, resulting in
    duplicate rows for the same person.

    Add merge_guest_customer_on_delayed_account_creation() hooked on
    woocommerce_created_customer at priority 5. It checks for the
    'delayed-account-creation' source and updates the guest row's user_id
    before update_registered_customer() runs, so the existing row is
    reused and order foreign keys are preserved.

    The fix is scoped to the delayed account creation flow only (order
    confirmation page) to avoid unverified email merges from other
    registration paths.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Add changelog entry for customer lookup duplicate fix

    * Remove duplicate changelog entry (CI already created one)

    * Fix negative test: assert guest row stays NULL instead of expecting 2 rows

    * Fix phpcs inline comment end char

    * Add void return type to merge_guest_customer_on_delayed_account_creation

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/63989-fix-customer-lookup-duplicate-on-delayed-registration b/plugins/woocommerce/changelog/63989-fix-customer-lookup-duplicate-on-delayed-registration
new file mode 100644
index 00000000000..9c8af58e3ce
--- /dev/null
+++ b/plugins/woocommerce/changelog/63989-fix-customer-lookup-duplicate-on-delayed-registration
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix duplicate wc_customer_lookup rows when a guest registers via delayed account creation on the order confirmation page.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index b3a39e6d110..ab840db60f9 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -111,6 +111,45 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
 		add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'anonymize_customer' ) );

 		add_action( 'woocommerce_analytics_delete_order_stats', array( __CLASS__, 'sync_on_order_delete' ), 15, 2 );
+
+		add_action( 'woocommerce_created_customer', array( __CLASS__, 'merge_guest_customer_on_delayed_account_creation' ), 5, 2 );
+	}
+
+	/**
+	 * When a customer registers via delayed account creation (order confirmation page),
+	 * merge the existing guest lookup row instead of creating a duplicate.
+	 *
+	 * This runs on woocommerce_created_customer at priority 5, before the analytics
+	 * hooks (woocommerce_new_customer) that call update_registered_customer(). It updates
+	 * the guest row's user_id so that update_registered_customer() finds it via
+	 * get_customer_id_by_user_id() and updates in place rather than inserting a new row.
+	 *
+	 * @param int   $customer_id       New WP user ID.
+	 * @param array $new_customer_data Customer data including 'source'.
+	 */
+	public static function merge_guest_customer_on_delayed_account_creation( $customer_id, $new_customer_data ): void {
+		if ( empty( $new_customer_data['source'] ) || 'delayed-account-creation' !== $new_customer_data['source'] ) {
+			return;
+		}
+
+		$email = $new_customer_data['user_email'] ?? '';
+		if ( empty( $email ) ) {
+			return;
+		}
+
+		$guest_customer_id = self::get_guest_id_by_email( $email );
+		if ( ! $guest_customer_id ) {
+			return;
+		}
+
+		global $wpdb;
+		$wpdb->update(
+			self::get_db_table_name(),
+			array( 'user_id' => $customer_id ),
+			array( 'customer_id' => $guest_customer_id ),
+			array( '%d' ),
+			array( '%d' )
+		);
 	}

 	/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-customers.php
index bb7283220fe..14d3bed5b78 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/reports/class-wc-tests-reports-customers.php
@@ -98,6 +98,101 @@ class WC_Admin_Tests_Reports_Customer extends WC_Unit_Test_Case {
 		$this->assertCount( 0, $this->get_customer_record( $customer_id ), 'customer removed' );
 	}

+	/**
+	 * Test that delayed account creation (order confirmation page) merges the
+	 * guest customer_lookup row instead of creating a duplicate.
+	 *
+	 * @covers \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::merge_guest_customer_on_delayed_account_creation
+	 */
+	public function test_delayed_account_creation_merges_guest_row() {
+		global $wpdb;
+
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$email = 'guest-merge-test@example.com';
+
+		// Create a guest order.
+		$order = WC_Helper_Order::create_order( 0 );
+		$order->set_billing_email( $email );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		// Verify guest row exists.
+		$guest_customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_guest_id_by_email( $email );
+		$this->assertNotFalse( $guest_customer_id, 'Guest customer row should exist after guest order.' );
+
+		// Register via delayed account creation (same source as the order confirmation page).
+		$user_id = wc_create_new_customer(
+			$email,
+			'',
+			'test_password',
+			array(
+				'first_name' => 'John',
+				'last_name'  => 'Doe',
+				'source'     => 'delayed-account-creation',
+			)
+		);
+		$this->assertNotWPError( $user_id );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		// The guest row should have been updated in place, not duplicated.
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT customer_id, user_id FROM {$wpdb->prefix}wc_customer_lookup WHERE email = %s",
+				$email
+			)
+		);
+
+		$this->assertCount( 1, $rows, 'There should be exactly one customer_lookup row for this email.' );
+		$this->assertEquals( $guest_customer_id, (int) $rows[0]->customer_id, 'The original customer_id should be preserved.' );
+		$this->assertEquals( $user_id, (int) $rows[0]->user_id, 'The user_id should be updated to the new registered user.' );
+	}
+
+	/**
+	 * Test that normal (non-delayed) registration does NOT merge a guest row.
+	 *
+	 * @covers \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::merge_guest_customer_on_delayed_account_creation
+	 */
+	public function test_normal_registration_does_not_merge_guest_row() {
+		global $wpdb;
+
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$email = 'normal-register-test@example.com';
+
+		// Create a guest order.
+		$order = WC_Helper_Order::create_order( 0 );
+		$order->set_billing_email( $email );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$guest_customer_id = \Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore::get_guest_id_by_email( $email );
+		$this->assertNotFalse( $guest_customer_id, 'Guest customer row should exist.' );
+
+		// Register via normal flow (no source = no merge).
+		$user_id = wc_create_new_customer( $email, '', 'test_password' );
+		$this->assertNotWPError( $user_id );
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		// The guest row must remain untouched: same customer_id, user_id still NULL.
+		// update_registered_customer skips users with no orders, so no second row is
+		// inserted either. What we're guarding against here is the merge function
+		// silently claiming the guest row for an unverified registration.
+		$rows = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT customer_id, user_id FROM {$wpdb->prefix}wc_customer_lookup WHERE customer_id = %d",
+				$guest_customer_id
+			)
+		);
+
+		$this->assertCount( 1, $rows, 'Guest row should still exist.' );
+		$this->assertNull( $rows[0]->user_id, 'Guest row user_id should remain NULL for normal registration.' );
+	}
+
 	/**
 	 * Get a customer's record from the database.
 	 *