Commit f9b451fa788 for woocommerce
commit f9b451fa78891197ea69454604d376c073a7e8bd
Author: Ján Mikláš <neosinner@gmail.com>
Date: Thu Aug 27 19:42:40 2026 +0200
Fix duplicate Analytics customers for manual orders (#67722)
Viewing the customer history of an email-less manual order under CPT
storage created a second blank wc_customer_lookup row on every view.
Use the read-only customer lookup when rendering, so Analytics customers
are only created by a legitimate import. Adds regression tests and a
changelog entry.
Claude-Session: https://claude.ai/code/session_01GU3W7KnYnsMo2dTVk65LPB
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooplug-4518-duplicate-manual-order-customers b/plugins/woocommerce/changelog/fix-wooplug-4518-duplicate-manual-order-customers
new file mode 100644
index 00000000000..e3574cf441a
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-4518-duplicate-manual-order-customers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent duplicate customer entries in Analytics after manually creating guest orders without an email address.
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
index f8721a53d53..537a2dd1495 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
@@ -3,8 +3,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Customers\Query as CustomersQuery;
-use Automattic\WooCommerce\Admin\Overrides\Order as AdminOrder;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
@@ -230,9 +230,7 @@ class CustomerHistory {
return 0;
}
- $report_order = $order instanceof AdminOrder ? $order : new AdminOrder( $order->get_id() );
-
- return (int) $report_order->get_report_customer_id();
+ return (int) CustomersDataStore::get_existing_customer_id_from_order( $order );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/MetaBoxes/CustomerHistoryTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/MetaBoxes/CustomerHistoryTest.php
index 891a412e037..2439bfbe846 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/MetaBoxes/CustomerHistoryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/MetaBoxes/CustomerHistoryTest.php
@@ -3,8 +3,10 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin\Orders\MetaBoxes;
+use Automattic\WooCommerce\Admin\API\Reports\Customers\DataStore as CustomersDataStore;
use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
use Automattic\WooCommerce\Admin\Overrides\Order as AdminOrder;
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -680,6 +682,61 @@ class CustomerHistoryTest extends WC_Unit_Test_Case {
$this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*100\.00/', $output, 'Should show total spend of 100' );
}
+ /**
+ * @testdox CPT fallback should not duplicate a guest customer without an email address.
+ */
+ public function test_cpt_fallback_does_not_duplicate_guest_without_email(): void {
+ global $wpdb;
+
+ $this->use_cpt_orders();
+ $this->assertFalse( OrderUtil::custom_orders_table_usage_is_enabled(), 'Test should use CPT order storage.' );
+
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_first_name( 'Guest' );
+ $order->set_billing_last_name( 'Customer' );
+ $order->set_billing_email( '' );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->set_total( 100 );
+ $order->save();
+
+ $this->assertSame( '', $order->get_billing_email( 'edit' ), 'Test order should not have a billing email.' );
+ \WC_Helper_Reports::reset_stats_dbs();
+
+ $new_customer_fired = 0;
+ $callback = static function () use ( &$new_customer_fired ) {
+ ++$new_customer_fired;
+ };
+ add_action( 'woocommerce_analytics_new_customer', $callback );
+
+ try {
+ ob_start();
+ try {
+ $this->sut->output( $order );
+ $output = (string) ob_get_contents();
+ } finally {
+ ob_end_clean();
+ }
+
+ $customer_lookup_table = CustomersDataStore::get_db_table_name();
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is provided by the data store.
+ $customers_after_render = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$customer_lookup_table}" );
+ $new_customers_after_render = $new_customer_fired;
+
+ OrdersStatsDataStore::update( new AdminOrder( $order->get_id() ) );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is provided by the data store.
+ $customers_after_import = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$customer_lookup_table}" );
+ } finally {
+ remove_action( 'woocommerce_analytics_new_customer', $callback );
+ }
+
+ $this->assertStringContainsString( 'order-attribution-total-orders', $output, 'Should render the metabox template.' );
+ $this->assertSame( 0, $customers_after_render, 'Rendering customer history should not create an analytics customer.' );
+ $this->assertSame( 0, $new_customers_after_render, 'Rendering customer history should not fire the new-customer action.' );
+ $this->assertSame( 1, $customers_after_import, 'Importing the order should create one analytics customer.' );
+ $this->assertSame( 1, $new_customer_fired, 'Importing the order should fire the new-customer action once.' );
+ }
+
/**
* Switches the order data store to CPT for fallback coverage.
*/