Commit 66c8d024ac1 for woocommerce
commit 66c8d024ac168acfec6da0901c2232a739c44f45
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Tue Mar 24 11:44:53 2026 +0800
fix: query real order data for CustomerHistory metabox (#63715)
* fix: query real order data for CustomerHistory metabox
Replace analytics-based CustomersQuery with direct SQL against
order tables so the metabox always shows real-time data regardless
of analytics sync status. Supports both HPOS and CPT storage.
* Add changefile(s) from automation for the following project(s): woocommerce
* fix: restore comment for auto-draft early return
* fix: resolve phpcs lint errors for prepared SQL usage
* fix: address code review feedback
- Add customer_id=0 filter to CPT guest query for HPOS/CPT parity
- Guard against non-array woocommerce_excluded_report_order_statuses
- Use precise regex assertions in tests instead of substring matches
* fix: address PR review issues for CustomerHistory metabox
- Apply woocommerce_analytics_excluded_order_statuses filter to maintain
parity with Analytics DataStore (prevents checkout-draft inflation)
- Add wpdb error logging after SQL queries to surface silent failures
- Fix CPT query to use %s instead of %d for meta_value VARCHAR column
- Add test assertions for total_spend and avg_order_value
- Add test for pending status exclusion
- Add test for guest order with no billing email edge case
* test: add HPOS/CPT dual-mode coverage for CustomerHistory tests
Exercise both storage modes (HPOS and CPT) using a data provider,
ensuring query_hpos() and query_cpt() paths are both tested.
* fix: add missing @param doc tags for $hpos_enabled in CustomerHistoryTest
* fix: remove @since tags from private methods in CustomerHistory
* fix: add is_array guard after apply_filters in get_excluded_statuses_sql
* fix: remove global filter in tearDown to prevent test-state leakage
* fix: account for refunds in CustomerHistory total spend calculation
Use derived table for filtered parent orders with LEFT JOIN against
a scoped refund aggregate in both HPOS and CPT paths. The refund
subquery is restricted to the customer's order IDs via IN subquery
to avoid scanning all refunds on every render.
HPOS refunds store total_amount as negative; CPT uses positive
_refund_amount meta — each path handles the sign accordingly.
Add tests for partial refund, full refund, and guest-email refund
scenarios across both storage modes.
* Limit CustomerHistory real-time lookup to HPOS
* fix: use JOIN for HPOS refund subquery, add method_exists guard for CPT
Replace IN (SELECT ...) with INNER JOIN for refund aggregation in HPOS
queries, eliminating the duplicated customer-filter subquery. Add
method_exists guard for get_report_customer_id on the CPT path to
prevent fatal errors with base WC_Order instances. Make data-accuracy
tests HPOS-only and add CPT fallback test.
* Add changefile(s) from automation for the following project(s): woocommerce
* fix: harden CustomerHistory error handling, improve tests and docs
- Check $wpdb->prepare() return value before executing queries
- Move DB query + error check into each branch for self-contained paths
- Use $wpdb->prepare() placeholders instead of esc_sql string concat
- Guard against empty excluded statuses array from filters
- Log warning for unexpected method_exists fallback path
- Fix misleading query_cpt() docblock description
- Broaden filter docblock scope, document return array shape
- Add cross-customer and cross-email isolation tests
- Tighten regex patterns in test assertions to prevent false positives
- Assert total_spend in excluded statuses test
* refactor: deduplicate DB query logic in query_hpos()
Move get_row() call and error check outside if/elseif branches.
Each branch only builds the SQL; shared execution + logging is in
one place.
* fix: remove PII (billing email) from error log message
* test: make CPT fallback test verify real analytics data
* fix: allow empty excluded statuses array from filter
* refactor: omit NOT IN clause when no statuses are excluded
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63715-fix-customer-history-real-time-data b/plugins/woocommerce/changelog/63715-fix-customer-history-real-time-data
new file mode 100644
index 00000000000..aa3da0d5b57
--- /dev/null
+++ b/plugins/woocommerce/changelog/63715-fix-customer-history-real-time-data
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix CustomerHistory metabox to query real-time order data (HPOS) instead of analytics tables, ensuring accurate customer order count and spend data regardless of analytics sync status.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
index 1396a9b7405..93a9b7c8b89 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
@@ -3,6 +3,8 @@
namespace Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes;
use Automattic\WooCommerce\Admin\API\Reports\Customers\Query as CustomersQuery;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Order;
/**
@@ -25,32 +27,153 @@ class CustomerHistory {
return;
}
- $customer_history = null;
+ $customer_history = $this->get_customer_history( $order );
- if ( method_exists( $order, 'get_report_customer_id' ) ) {
- $customer_history = $this->get_customer_history( $order->get_report_customer_id() );
- }
+ wc_get_template( 'order/customer-history.php', $customer_history );
+ }
- if ( ! $customer_history ) {
- $customer_history = array(
- 'orders_count' => 0,
- 'total_spend' => 0,
- 'avg_order_value' => 0,
+ /**
+ * Get the order history for the customer.
+ *
+ * @param WC_Order $order The order object.
+ *
+ * @return array{orders_count: int, total_spend: float, avg_order_value: float} Order count, total spend, and average order value.
+ */
+ private function get_customer_history( WC_Order $order ): array {
+ if ( OrderUtil::custom_orders_table_usage_is_enabled() ) {
+ $customer_id = $order->get_customer_id();
+ $billing_email = $order->get_billing_email();
+ $result = $this->query_hpos( $customer_id, $billing_email );
+ } elseif ( method_exists( $order, 'get_report_customer_id' ) ) {
+ $result = $this->query_cpt( $order->get_report_customer_id() );
+ } else {
+ wc_get_logger()->warning(
+ 'CustomerHistory: Order object does not have get_report_customer_id method.',
+ array( 'source' => 'customer-history' )
+ );
+ $result = (object) array(
+ 'orders_count' => 0,
+ 'total_spend' => 0,
);
}
- wc_get_template( 'order/customer-history.php', $customer_history );
+ $orders_count = (int) ( $result->orders_count ?? 0 );
+ $total_spend = (float) ( $result->total_spend ?? 0 );
+
+ return array(
+ 'orders_count' => $orders_count,
+ 'total_spend' => $total_spend,
+ 'avg_order_value' => $orders_count > 0 ? $total_spend / $orders_count : 0,
+ );
}
/**
- * Get the order history for the customer (data matches Customers report).
+ * Query customer order stats from HPOS tables.
*
- * @param int $customer_report_id The reports customer ID (not necessarily User ID).
+ * @param int $customer_id The customer user ID.
+ * @param string $billing_email The billing email address.
*
- * @return array|null Order count, total spend, and average spend per order.
+ * @return object Object with orders_count and total_spend properties.
*/
- private function get_customer_history( $customer_report_id ): ?array {
+ private function query_hpos( int $customer_id, string $billing_email ): object {
+ global $wpdb;
+
+ $default = (object) array(
+ 'orders_count' => 0,
+ 'total_spend' => 0,
+ );
+
+ $excluded_statuses_sql = $this->get_excluded_statuses_sql();
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+
+ $sql = null;
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ if ( $customer_id > 0 ) {
+ $status_filter = $excluded_statuses_sql ? "AND status NOT IN $excluded_statuses_sql" : '';
+ $co_status_filter = $excluded_statuses_sql ? "AND co.status NOT IN $excluded_statuses_sql" : '';
+
+ $sql = $wpdb->prepare(
+ "SELECT COUNT(*) AS orders_count,
+ COALESCE( SUM( filtered.total_amount ), 0 ) + COALESCE( SUM( r.refund_total ), 0 ) AS total_spend
+ FROM (
+ SELECT id, total_amount
+ FROM %i
+ WHERE customer_id = %d AND type = 'shop_order' $status_filter
+ ) AS filtered
+ LEFT JOIN (
+ SELECT rp.parent_order_id, SUM( rp.total_amount ) AS refund_total
+ FROM %i AS rp
+ INNER JOIN %i AS co ON rp.parent_order_id = co.id
+ WHERE rp.type = 'shop_order_refund'
+ AND co.customer_id = %d AND co.type = 'shop_order' $co_status_filter
+ GROUP BY rp.parent_order_id
+ ) AS r ON filtered.id = r.parent_order_id",
+ $orders_table,
+ $customer_id,
+ $orders_table,
+ $orders_table,
+ $customer_id
+ );
+ } elseif ( '' !== $billing_email ) {
+ $addresses_table = OrdersTableDataStore::get_addresses_table_name();
+ $o_status_filter = $excluded_statuses_sql ? "AND o.status NOT IN $excluded_statuses_sql" : '';
+ $co_status_filter = $excluded_statuses_sql ? "AND co.status NOT IN $excluded_statuses_sql" : '';
+
+ $sql = $wpdb->prepare(
+ "SELECT COUNT(*) AS orders_count,
+ COALESCE( SUM( filtered.total_amount ), 0 ) + COALESCE( SUM( r.refund_total ), 0 ) AS total_spend
+ FROM (
+ SELECT o.id, o.total_amount
+ FROM %i AS o
+ INNER JOIN %i AS a ON o.id = a.order_id AND a.address_type = 'billing'
+ WHERE o.customer_id = 0 AND a.email = %s AND o.type = 'shop_order' $o_status_filter
+ ) AS filtered
+ LEFT JOIN (
+ SELECT rp.parent_order_id, SUM( rp.total_amount ) AS refund_total
+ FROM %i AS rp
+ INNER JOIN %i AS co ON rp.parent_order_id = co.id
+ INNER JOIN %i AS ca ON co.id = ca.order_id AND ca.address_type = 'billing'
+ WHERE rp.type = 'shop_order_refund'
+ AND co.customer_id = 0 AND ca.email = %s AND co.type = 'shop_order' $co_status_filter
+ GROUP BY rp.parent_order_id
+ ) AS r ON filtered.id = r.parent_order_id",
+ $orders_table,
+ $addresses_table,
+ $billing_email,
+ $orders_table,
+ $orders_table,
+ $addresses_table,
+ $billing_email
+ );
+ }
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( null === $sql ) {
+ return $default;
+ }
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared above.
+ $row = $wpdb->get_row( $sql );
+ if ( $wpdb->last_error ) {
+ wc_get_logger()->error(
+ sprintf( 'CustomerHistory: Failed to query HPOS order stats for customer_id=%d. DB error: %s', $customer_id, $wpdb->last_error ),
+ array( 'source' => 'customer-history' )
+ );
+ }
+
+ return $row ?? $default;
+ }
+
+ /**
+ * Query customer order stats via the Analytics Customers report (legacy fallback when HPOS is not active).
+ *
+ * @param int $customer_report_id The reports customer ID.
+ *
+ * @return object Object with orders_count and total_spend properties.
+ */
+ private function query_cpt( int $customer_report_id ): object {
$args = array(
'customers' => array( $customer_report_id ),
// If unset, these params have default values that affect the results.
@@ -60,7 +183,54 @@ class CustomerHistory {
$customers_query = new CustomersQuery( $args );
$customer_data = $customers_query->get_data();
- return $customer_data->data[0] ?? null;
+ $customer_row = $customer_data->data[0] ?? null;
+
+ return (object) array(
+ 'orders_count' => $customer_row['orders_count'] ?? 0,
+ 'total_spend' => $customer_row['total_spend'] ?? 0,
+ );
}
+ /**
+ * Get the SQL fragment for excluded order statuses.
+ *
+ * @return string SQL IN clause, e.g. ( 'auto-draft','trash','wc-pending','wc-failed',... ), or empty string if no statuses are excluded.
+ */
+ private function get_excluded_statuses_sql(): string {
+ global $wpdb;
+
+ $excluded_statuses = get_option( 'woocommerce_excluded_report_order_statuses', array( 'pending', 'failed', 'cancelled' ) );
+ if ( ! is_array( $excluded_statuses ) ) {
+ $excluded_statuses = array( 'pending', 'failed', 'cancelled' );
+ }
+ $excluded_statuses = array_merge( array( 'auto-draft', 'trash' ), $excluded_statuses );
+
+ /**
+ * Filter the list of excluded order statuses for customer history and analytics reports.
+ *
+ * @since 4.0.0
+ * @param array $excluded_statuses Order statuses to exclude.
+ */
+ $excluded_statuses = apply_filters( 'woocommerce_analytics_excluded_order_statuses', $excluded_statuses );
+ if ( ! is_array( $excluded_statuses ) ) {
+ $excluded_statuses = array( 'auto-draft', 'trash', 'pending', 'failed', 'cancelled' );
+ }
+
+ if ( empty( $excluded_statuses ) ) {
+ return '';
+ }
+
+ $prefixed = array_map(
+ function ( $status ) {
+ $status = sanitize_title( $status );
+ return 'auto-draft' === $status || 'trash' === $status ? $status : 'wc-' . $status;
+ },
+ $excluded_statuses
+ );
+
+ $placeholders = implode( ',', array_fill( 0, count( $prefixed ), '%s' ) );
+
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders is a safe string of %s tokens.
+ return $wpdb->prepare( "( $placeholders )", $prefixed );
+ }
}
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
new file mode 100644
index 00000000000..466525d307d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Orders/MetaBoxes/CustomerHistoryTest.php
@@ -0,0 +1,401 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Orders\MetaBoxes;
+
+use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersStatsDataStore;
+use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
+use Automattic\WooCommerce\RestApi\UnitTests\HPOSToggleTrait;
+use WC_Helper_Order;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the CustomerHistory class.
+ */
+class CustomerHistoryTest extends WC_Unit_Test_Case {
+ use HPOSToggleTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var CustomerHistory
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ $this->setup_cot();
+ $this->sut = new CustomerHistory();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->clean_up_cot_setup();
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should return correct count, total, and average for a registered customer with multiple orders (HPOS).
+ */
+ public function test_registered_customer_with_multiple_orders(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_id = $this->factory->user->create();
+
+ $order1 = WC_Helper_Order::create_order( $customer_id );
+ $order1->set_status( 'completed' );
+ $order1->set_total( 100 );
+ $order1->save();
+
+ $order2 = WC_Helper_Order::create_order( $customer_id );
+ $order2->set_status( 'completed' );
+ $order2->set_total( 200 );
+ $order2->save();
+
+ ob_start();
+ $this->sut->output( $order1 );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*2\s*</', $output, 'Should show 2 orders for the customer' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*300\.00/', $output, 'Should show total spend of 300' );
+ $this->assertMatchesRegularExpression( '/order-attribution-average-order-value">\s*.*150\.00/', $output, 'Should show average order value of 150' );
+ }
+
+ /**
+ * @testdox Should fetch data correctly for a guest customer matched by billing email (HPOS).
+ */
+ public function test_guest_customer_by_email(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $email = 'guest-test@example.com';
+
+ $order1 = WC_Helper_Order::create_order( 0 );
+ $order1->set_billing_email( $email );
+ $order1->set_status( 'completed' );
+ $order1->set_total( 75 );
+ $order1->save();
+
+ $order2 = WC_Helper_Order::create_order( 0 );
+ $order2->set_billing_email( $email );
+ $order2->set_status( 'processing' );
+ $order2->set_total( 25 );
+ $order2->save();
+
+ ob_start();
+ $this->sut->output( $order1 );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*2\s*</', $output, 'Should show 2 orders for the guest customer' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*100\.00/', $output, 'Should show total spend of 100' );
+ $this->assertMatchesRegularExpression( '/order-attribution-average-order-value">\s*.*50\.00/', $output, 'Should show average order value of 50' );
+ }
+
+ /**
+ * @testdox Should not count orders with excluded statuses like pending, cancelled, and failed (HPOS).
+ */
+ public function test_excluded_statuses_not_counted(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_id = $this->factory->user->create();
+
+ $order_good = WC_Helper_Order::create_order( $customer_id );
+ $order_good->set_status( 'completed' );
+ $order_good->set_total( 100 );
+ $order_good->save();
+
+ $order_cancelled = WC_Helper_Order::create_order( $customer_id );
+ $order_cancelled->set_status( 'cancelled' );
+ $order_cancelled->set_total( 50 );
+ $order_cancelled->save();
+
+ $order_failed = WC_Helper_Order::create_order( $customer_id );
+ $order_failed->set_status( 'failed' );
+ $order_failed->set_total( 30 );
+ $order_failed->save();
+
+ $order_pending = WC_Helper_Order::create_order( $customer_id );
+ $order_pending->set_status( 'pending' );
+ $order_pending->set_total( 20 );
+ $order_pending->save();
+
+ ob_start();
+ $this->sut->output( $order_good );
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'order-attribution-total-orders', $output );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output, 'Should only count the completed order' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*100\.00/', $output, 'Should only sum spend from the completed order' );
+ }
+
+ /**
+ * @testdox Should return early without output for auto-draft orders.
+ */
+ public function test_auto_draft_returns_early(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->set_status( 'auto-draft' );
+ $order->save();
+
+ ob_start();
+ $this->sut->output( $order );
+ $output = ob_get_clean();
+
+ $this->assertEmpty( $output, 'Should produce no output for auto-draft orders' );
+ }
+
+ /**
+ * @testdox Should show zero data for guest order with no billing email (HPOS).
+ */
+ public function test_guest_with_no_email_shows_zero(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( '' );
+ $order->set_status( 'completed' );
+ $order->set_total( 50 );
+ $order->save();
+
+ ob_start();
+ $this->sut->output( $order );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*0\s*</', $output, 'Should show 0 orders for guest with no email' );
+ }
+
+ /**
+ * @testdox Should show zero data when no matching orders exist for the customer (HPOS).
+ */
+ public function test_no_matching_orders_shows_zero(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_id = $this->factory->user->create();
+
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->set_status( 'cancelled' );
+ $order->set_total( 100 );
+ $order->save();
+
+ ob_start();
+ $this->sut->output( $order );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*0\s*</', $output, 'Should show 0 orders when all are excluded' );
+ }
+
+ /**
+ * @testdox Should deduct partial refund from total spend (HPOS).
+ */
+ public function test_partial_refund_deducted_from_total_spend(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_id = $this->factory->user->create();
+
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->set_status( 'completed' );
+ $order->set_total( 200 );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 50,
+ 'reason' => 'Partial refund test',
+ )
+ );
+
+ ob_start();
+ $this->sut->output( $order );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output, 'Should still count 1 order after partial refund' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*150\.00/', $output, 'Should show net spend of 150 after 50 refund' );
+ $this->assertMatchesRegularExpression( '/order-attribution-average-order-value">\s*.*150\.00/', $output, 'Should show average of 150 after partial refund' );
+ }
+
+ /**
+ * @testdox Should deduct full refund from total spend (HPOS).
+ */
+ public function test_full_refund_deducted_from_total_spend(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_id = $this->factory->user->create();
+
+ $order1 = WC_Helper_Order::create_order( $customer_id );
+ $order1->set_status( 'completed' );
+ $order1->set_total( 100 );
+ $order1->save();
+
+ $order2 = WC_Helper_Order::create_order( $customer_id );
+ $order2->set_status( 'completed' );
+ $order2->set_total( 200 );
+ $order2->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order1->get_id(),
+ 'amount' => 100,
+ 'reason' => 'Full refund test',
+ )
+ );
+
+ ob_start();
+ $this->sut->output( $order1 );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*2\s*</', $output, 'Should still count 2 orders after full refund' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*200\.00/', $output, 'Should show net spend of 200 after full refund of first order' );
+ $this->assertMatchesRegularExpression( '/order-attribution-average-order-value">\s*.*100\.00/', $output, 'Should show average of 100 (200 net / 2 orders)' );
+ }
+
+ /**
+ * @testdox Should deduct refund from guest order total spend (HPOS).
+ */
+ public function test_guest_order_refund_deducted_from_total_spend(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $email = 'guest-refund@example.com';
+
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_billing_email( $email );
+ $order->set_status( 'completed' );
+ $order->set_total( 100 );
+ $order->save();
+
+ wc_create_refund(
+ array(
+ 'order_id' => $order->get_id(),
+ 'amount' => 30,
+ 'reason' => 'Guest partial refund test',
+ )
+ );
+
+ ob_start();
+ $this->sut->output( $order );
+ $output = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output, 'Should still count 1 order after guest refund' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*70\.00/', $output, 'Should show net spend of 70 after 30 refund on guest order' );
+ }
+
+ /**
+ * @testdox Should only count orders for the specific registered customer, not other customers (HPOS).
+ */
+ public function test_registered_customer_isolation(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $customer_a = $this->factory->user->create();
+ $customer_b = $this->factory->user->create();
+
+ $order_a = WC_Helper_Order::create_order( $customer_a );
+ $order_a->set_status( 'completed' );
+ $order_a->set_total( 100 );
+ $order_a->save();
+
+ $order_b1 = WC_Helper_Order::create_order( $customer_b );
+ $order_b1->set_status( 'completed' );
+ $order_b1->set_total( 200 );
+ $order_b1->save();
+
+ $order_b2 = WC_Helper_Order::create_order( $customer_b );
+ $order_b2->set_status( 'completed' );
+ $order_b2->set_total( 300 );
+ $order_b2->save();
+
+ ob_start();
+ $this->sut->output( $order_a );
+ $output_a = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output_a, 'Customer A should see only their 1 order' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*100\.00/', $output_a, 'Customer A should see total spend of 100' );
+
+ ob_start();
+ $this->sut->output( $order_b1 );
+ $output_b = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*2\s*</', $output_b, 'Customer B should see their 2 orders' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*500\.00/', $output_b, 'Customer B should see total spend of 500' );
+ }
+
+ /**
+ * @testdox Should only count orders for the specific guest email, not other guest emails (HPOS).
+ */
+ public function test_guest_customer_email_isolation(): void {
+ $this->toggle_cot_feature_and_usage( true );
+
+ $email_a = 'guest-a@example.com';
+ $email_b = 'guest-b@example.com';
+
+ $order_a = WC_Helper_Order::create_order( 0 );
+ $order_a->set_billing_email( $email_a );
+ $order_a->set_status( 'completed' );
+ $order_a->set_total( 50 );
+ $order_a->save();
+
+ $order_b1 = WC_Helper_Order::create_order( 0 );
+ $order_b1->set_billing_email( $email_b );
+ $order_b1->set_status( 'completed' );
+ $order_b1->set_total( 75 );
+ $order_b1->save();
+
+ $order_b2 = WC_Helper_Order::create_order( 0 );
+ $order_b2->set_billing_email( $email_b );
+ $order_b2->set_status( 'completed' );
+ $order_b2->set_total( 125 );
+ $order_b2->save();
+
+ ob_start();
+ $this->sut->output( $order_a );
+ $output_a = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output_a, 'Guest A should see only their 1 order' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*50\.00/', $output_a, 'Guest A should see total spend of 50' );
+
+ ob_start();
+ $this->sut->output( $order_b1 );
+ $output_b = ob_get_clean();
+
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*2\s*</', $output_b, 'Guest B should see their 2 orders' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*200\.00/', $output_b, 'Guest B should see total spend of 200' );
+ }
+
+ /**
+ * @testdox CPT fallback should render correct customer history from analytics tables.
+ */
+ public function test_cpt_fallback_renders_with_analytics_data(): void {
+ $this->toggle_cot_feature_and_usage( false );
+
+ \WC_Helper_Reports::reset_stats_dbs();
+
+ // Register the Override\Order class so wc_get_order() returns an instance
+ // with get_report_customer_id(), which the CPT path requires.
+ \Automattic\WooCommerce\Admin\Overrides\Order::add_filters();
+
+ $customer_id = $this->factory->user->create();
+
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->set_status( 'completed' );
+ $order->set_total( 100 );
+ $order->save();
+
+ OrdersStatsDataStore::sync_order( $order->get_id() );
+
+ // Re-fetch with Override class so output() takes the CPT path.
+ $override_order = wc_get_order( $order->get_id() );
+
+ ob_start();
+ $this->sut->output( $override_order );
+ $output = ob_get_clean();
+
+ remove_filter( 'woocommerce_order_class', array( \Automattic\WooCommerce\Admin\Overrides\Order::class, 'order_class_name' ) );
+
+ $this->assertStringContainsString( 'order-attribution-total-orders', $output, 'Should render the metabox template' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-orders">\s*1\s*</', $output, 'Should show 1 order from analytics data' );
+ $this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*100\.00/', $output, 'Should show total spend of 100' );
+ }
+}