Commit f0621864ade for woocommerce
commit f0621864ade01d4dae2412ecd87268800fe02976
Author: Daniel Kam <hey@danielk.am>
Date: Tue Oct 27 21:41:45 2026 +0800
Fix Customer history summing order totals across currencies (#69457)
* Fix Customer history summing order totals across currencies
* Add changelog entries for Customer history per-currency totals
* Set the Customer history template version to 11.4.0
Trunk moved to 11.4.0-dev after this PR was opened, so the fix ships in 11.4.0. The template header, the "Passed since" notes on the new variables, and the compatibility comment now name that release.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update plugins/woocommerce/changelog/fix-43781-customer-history-per-currency
---------
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-43781-customer-history-per-currency b/plugins/woocommerce/changelog/fix-43781-customer-history-per-currency
new file mode 100644
index 00000000000..273946bc0b8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-43781-customer-history-per-currency
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show Customer history totals per currency instead of adding currencies together, on stores using High-Performance Order Storage.
diff --git a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
index 165ef1d852a..810e13ce37e 100644
--- a/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
+++ b/plugins/woocommerce/src/Internal/Admin/Orders/MetaBoxes/CustomerHistory.php
@@ -46,29 +46,72 @@ class CustomerHistory {
/**
* Get the order history for the customer.
*
+ * Totals are also returned per order currency, because amounts in different
+ * currencies cannot be added together. `total_spend` and `avg_order_value` still
+ * cover every order whatever its currency, so template overrides that read only
+ * those keep receiving the same values.
+ *
* @param WC_Order $order The order object.
*
- * @return array{orders_count: int, total_spend: float, avg_order_value: float, tooltip: string} Order count, total spend, average order value, and tooltip text.
+ * @return array{orders_count: int, total_spend: float, avg_order_value: float, tooltip: string, totals_per_currency: array<string, float>, counts_per_currency: array<string, int>, averages_per_currency: array<string, float>} Order count, total spend, average order value, tooltip text, and the totals, order counts and averages per currency.
*/
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 );
+ $rows = $this->query_hpos( $customer_id, $billing_email );
} else {
+ // The Analytics Customers report holds one total per customer with no currency
+ // breakdown, so legacy order storage keeps showing it as a single amount.
$customer_report_id = $this->get_cpt_report_customer_id( $order );
- if ( $customer_report_id > 0 ) {
- $result = $this->query_cpt( $customer_report_id );
- } else {
- $result = (object) array(
- 'orders_count' => 0,
- 'total_spend' => 0,
- );
+ $rows = $customer_report_id > 0 ? array( $this->query_cpt( $customer_report_id ) ) : array();
+ }
+
+ $store_currency = get_woocommerce_currency();
+ $orders_count = 0;
+ $total_spend = 0.0;
+ $per_currency = array();
+
+ // Stored codes are matched to registered currencies whatever their letter case.
+ $registered_codes = array();
+ foreach ( array_keys( get_woocommerce_currencies() ) as $code ) {
+ $registered_codes[ strtoupper( (string) $code ) ] = (string) $code;
+ }
+
+ foreach ( $rows as $row ) {
+ $currency_orders_count = (int) ( $row->orders_count ?? 0 );
+ if ( $currency_orders_count < 1 ) {
+ continue;
}
+
+ // Orders saved without a currency keep counting towards the store currency.
+ $currency = trim( (string) ( $row->currency ?? '' ) );
+ $currency = '' === $currency ? $store_currency : ( $registered_codes[ strtoupper( $currency ) ] ?? $currency );
+
+ $currency_total = (float) ( $row->total_spend ?? 0 );
+ $orders_count += $currency_orders_count;
+ $total_spend += $currency_total;
+
+ $per_currency[ $currency ] = array(
+ 'count' => ( $per_currency[ $currency ]['count'] ?? 0 ) + $currency_orders_count,
+ 'total' => ( $per_currency[ $currency ]['total'] ?? 0.0 ) + $currency_total,
+ );
+ }
+
+ // Store currency first, then the other currencies by code.
+ ksort( $per_currency, SORT_STRING );
+ if ( isset( $per_currency[ $store_currency ] ) ) {
+ $per_currency = array( $store_currency => $per_currency[ $store_currency ] ) + $per_currency;
}
- $orders_count = (int) ( $result->orders_count ?? 0 );
- $total_spend = (float) ( $result->total_spend ?? 0 );
+ $totals_per_currency = array();
+ $counts_per_currency = array();
+ $averages_per_currency = array();
+ foreach ( $per_currency as $currency => $history ) {
+ $totals_per_currency[ $currency ] = $history['total'];
+ $counts_per_currency[ $currency ] = $history['count'];
+ $averages_per_currency[ $currency ] = $history['total'] / $history['count'];
+ }
// Build a dynamic tooltip listing the excluded statuses by their translated labels.
// Internal statuses (auto-draft, trash) are naturally filtered out because they
@@ -98,10 +141,13 @@ class CustomerHistory {
}
return array(
- 'orders_count' => $orders_count,
- 'total_spend' => $total_spend,
- 'avg_order_value' => $orders_count > 0 ? $total_spend / $orders_count : 0,
- 'tooltip' => $tooltip,
+ 'orders_count' => $orders_count,
+ 'total_spend' => $total_spend,
+ 'avg_order_value' => $orders_count > 0 ? $total_spend / $orders_count : 0,
+ 'tooltip' => $tooltip,
+ 'totals_per_currency' => $totals_per_currency,
+ 'counts_per_currency' => $counts_per_currency,
+ 'averages_per_currency' => $averages_per_currency,
);
}
@@ -111,16 +157,11 @@ class CustomerHistory {
* @param int $customer_id The customer user ID.
* @param string $billing_email The billing email address.
*
- * @return object Object with orders_count and total_spend properties.
+ * @return object[] One object per order currency, with currency, orders_count and total_spend properties.
*/
- private function query_hpos( int $customer_id, string $billing_email ): object {
+ private function query_hpos( int $customer_id, string $billing_email ): array {
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();
@@ -132,10 +173,11 @@ class CustomerHistory {
$co_status_filter = $excluded_statuses_sql ? "AND co.status NOT IN $excluded_statuses_sql" : '';
$sql = $wpdb->prepare(
- "SELECT COUNT(*) AS orders_count,
+ "SELECT filtered.currency AS currency,
+ COUNT(*) AS orders_count,
COALESCE( SUM( filtered.total_amount ), 0 ) + COALESCE( SUM( r.refund_total ), 0 ) AS total_spend
FROM (
- SELECT id, total_amount
+ SELECT id, total_amount, currency
FROM {$orders_table}
WHERE customer_id = %d AND type = 'shop_order' $status_filter
) AS filtered
@@ -146,7 +188,8 @@ class CustomerHistory {
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",
+ ) AS r ON filtered.id = r.parent_order_id
+ GROUP BY filtered.currency",
$customer_id,
$customer_id
);
@@ -156,10 +199,11 @@ class CustomerHistory {
$co_status_filter = $excluded_statuses_sql ? "AND co.status NOT IN $excluded_statuses_sql" : '';
$sql = $wpdb->prepare(
- "SELECT COUNT(*) AS orders_count,
+ "SELECT filtered.currency AS currency,
+ 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
+ SELECT o.id, o.total_amount, o.currency
FROM {$orders_table} AS o
INNER JOIN {$addresses_table} 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
@@ -172,7 +216,8 @@ class CustomerHistory {
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",
+ ) AS r ON filtered.id = r.parent_order_id
+ GROUP BY filtered.currency",
$billing_email,
$billing_email
);
@@ -180,11 +225,11 @@ class CustomerHistory {
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( null === $sql ) {
- return $default;
+ return array();
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared above.
- $row = $wpdb->get_row( $sql );
+ $rows = $wpdb->get_results( $sql );
if ( $wpdb->last_error ) {
wc_get_logger()->error(
@@ -193,7 +238,7 @@ class CustomerHistory {
);
}
- return $row ?? $default;
+ return is_array( $rows ) ? $rows : array();
}
/**
diff --git a/plugins/woocommerce/templates/order/customer-history.php b/plugins/woocommerce/templates/order/customer-history.php
index 7532b200be7..f3ae1456368 100644
--- a/plugins/woocommerce/templates/order/customer-history.php
+++ b/plugins/woocommerce/templates/order/customer-history.php
@@ -6,7 +6,7 @@
*
* @see Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory
* @package WooCommerce\Templates
- * @version 10.8.0
+ * @version 11.4.0
*/
declare( strict_types=1 );
@@ -16,11 +16,23 @@ defined( 'ABSPATH' ) || exit;
/**
* Variables used in this file.
*
- * @var int $orders_count The number of paid orders placed by the current customer.
- * @var float $total_spend The total money spent by the current customer.
- * @var float $avg_order_value The average money spent by the current customer.
- * @var string $tooltip The tooltip text for the "Total orders" heading.
+ * @var int $orders_count The number of paid orders placed by the current customer.
+ * @var float $total_spend The total money spent by the current customer, across every order currency.
+ * @var float $avg_order_value The average money spent by the current customer, across every order currency.
+ * @var string $tooltip The tooltip text for the "Total orders" heading.
+ * @var array<string, float> $totals_per_currency The total money spent by the current customer, keyed by order currency. Passed since 11.4.0.
+ * @var array<string, int> $counts_per_currency The number of paid orders placed by the current customer, keyed by order currency. Passed since 11.4.0.
+ * @var array<string, float> $averages_per_currency The average money spent by the current customer, keyed by order currency. Passed since 11.4.0.
*/
+
+// Callers written before 11.4.0 pass only the single total and average.
+$totals_per_currency = is_array( $totals_per_currency ?? null ) ? $totals_per_currency : array();
+$counts_per_currency = is_array( $counts_per_currency ?? null ) ? $counts_per_currency : array();
+$averages_per_currency = is_array( $averages_per_currency ?? null ) ? $averages_per_currency : array();
+
+// A customer whose orders are all in the store currency gets a single total. Any other currency is listed and labelled.
+$has_several_currencies = count( $totals_per_currency ) > 1;
+$show_per_currency = $has_several_currencies || ( 1 === count( $totals_per_currency ) && ! isset( $totals_per_currency[ get_woocommerce_currency() ] ) );
?>
<div class="customer-history order-attribution-metabox">
@@ -40,17 +52,51 @@ defined( 'ABSPATH' ) || exit;
esc_html_e( 'Total revenue', 'woocommerce' );
echo wp_kses_post(
wc_help_tip(
- __( "This is the Customer Lifetime Value, or the total amount you have earned from this customer's orders.", 'woocommerce' )
+ $has_several_currencies
+ ? __( "This is the Customer Lifetime Value, or the total amount you have earned from this customer's orders. Orders in different currencies are totaled separately.", 'woocommerce' )
+ : __( "This is the Customer Lifetime Value, or the total amount you have earned from this customer's orders.", 'woocommerce' )
)
);
?>
</h4>
+<?php if ( $show_per_currency ) : ?>
+ <?php foreach ( $totals_per_currency as $currency => $currency_total ) : ?>
+ <div class="order-attribution-currency-row" data-currency="<?php echo esc_attr( (string) $currency ); ?>">
+ <span class="order-attribution-total-spend"><?php echo wp_kses_post( wc_price( (float) $currency_total, array( 'currency' => (string) $currency ) ) ); ?></span>
+ <span class="order-attribution-currency-code"><?php echo esc_html( (string) $currency ); ?></span>
+ <?php if ( $has_several_currencies ) : ?>
+ <?php $currency_orders_count = (int) ( $counts_per_currency[ $currency ] ?? 0 ); ?>
+ <small class="order-attribution-currency-orders">
+ <?php
+ echo esc_html(
+ sprintf(
+ /* translators: %s: number of orders in one currency */
+ _n( '(%s order)', '(%s orders)', $currency_orders_count, 'woocommerce' ),
+ number_format_i18n( $currency_orders_count )
+ )
+ );
+ ?>
+ </small>
+ <?php endif; ?>
+ </div>
+ <?php endforeach; ?>
+<?php else : ?>
<span class="order-attribution-total-spend">
<?php echo wp_kses_post( wc_price( $total_spend ) ); ?>
</span>
+<?php endif; ?>
<h4><?php esc_html_e( 'Average order value', 'woocommerce' ); ?></h4>
+<?php if ( $show_per_currency ) : ?>
+ <?php foreach ( $averages_per_currency as $currency => $currency_average ) : ?>
+ <div class="order-attribution-currency-row" data-currency="<?php echo esc_attr( (string) $currency ); ?>">
+ <span class="order-attribution-average-order-value"><?php echo wp_kses_post( wc_price( (float) $currency_average, array( 'currency' => (string) $currency ) ) ); ?></span>
+ <span class="order-attribution-currency-code"><?php echo esc_html( (string) $currency ); ?></span>
+ </div>
+ <?php endforeach; ?>
+<?php else : ?>
<span class="order-attribution-average-order-value">
<?php echo wp_kses_post( wc_price( $avg_order_value ) ); ?>
</span>
+<?php endif; ?>
</div>
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 4c7e61ac7a3..f3648fe2105 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
@@ -8,9 +8,13 @@ use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrdersSta
use Automattic\WooCommerce\Admin\Overrides\Order as AdminOrder;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\Admin\Orders\MetaBoxes\CustomerHistory;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\Utilities\OrderUtil;
+use DOMDocument;
+use DOMXPath;
use WC_Helper_Order;
+use WC_Order;
use WC_Unit_Test_Case;
/**
@@ -485,6 +489,392 @@ class CustomerHistoryTest extends WC_Unit_Test_Case {
$this->assertMatchesRegularExpression( '/order-attribution-total-spend">\s*.*200\.00/', $output_b, 'Guest B should see total spend of 200' );
}
+ /**
+ * @testdox Should total and average each currency separately for a customer with orders in more than one currency (HPOS).
+ *
+ * @testWith ["registered"]
+ * ["guest"]
+ *
+ * @param string $customer_type Whether the orders belong to a registered customer or a guest.
+ */
+ public function test_mixed_currency_orders_are_totalled_per_currency( string $customer_type ): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = 'registered' === $customer_type ? $this->factory->user->create() : 0;
+ $billing_email = 'mixed-currencies@example.com';
+
+ $order = $this->create_completed_order( $customer_id, 'GBP', 36.00, $billing_email );
+ $this->create_completed_order( $customer_id, 'EUR', 41.90, $billing_email );
+ $this->create_completed_order( $customer_id, 'EUR', 10.00, $billing_email );
+ $this->create_completed_order( $customer_id, 'AUD', 20.00, $billing_email );
+
+ $output = $this->render_customer_history( $order );
+
+ $this->assertSame( '4', self::get_text( $output, 'order-attribution-total-orders' ), 'Should count the orders in every currency' );
+ $this->assertStringNotContainsString( '107.90', $output, 'Amounts in different currencies should not be added together' );
+ $this->assertSame(
+ array(
+ 'EUR' => array(
+ 'amount' => '€51.90',
+ 'code' => 'EUR',
+ 'orders' => '(2 orders)',
+ ),
+ 'AUD' => array(
+ 'amount' => '$20.00',
+ 'code' => 'AUD',
+ 'orders' => '(1 order)',
+ ),
+ 'GBP' => array(
+ 'amount' => '£36.00',
+ 'code' => 'GBP',
+ 'orders' => '(1 order)',
+ ),
+ ),
+ self::get_currency_rows( $output, 'order-attribution-total-spend' ),
+ 'Should show one total per currency with its own order count, store currency first and the rest by code'
+ );
+ $this->assertSame(
+ array(
+ 'EUR' => array(
+ 'amount' => '€25.95',
+ 'code' => 'EUR',
+ 'orders' => '',
+ ),
+ 'AUD' => array(
+ 'amount' => '$20.00',
+ 'code' => 'AUD',
+ 'orders' => '',
+ ),
+ 'GBP' => array(
+ 'amount' => '£36.00',
+ 'code' => 'GBP',
+ 'orders' => '',
+ ),
+ ),
+ self::get_currency_rows( $output, 'order-attribution-average-order-value' ),
+ 'Should divide each currency total by the number of orders in that currency'
+ );
+ $this->assertStringContainsString( 'Orders in different currencies are totaled separately.', $output, 'The total revenue tooltip should explain the separate totals' );
+ }
+
+ /**
+ * @testdox Should deduct a refund only from the total of the currency the refunded order was placed in (HPOS).
+ */
+ public function test_refund_reduces_only_its_own_currency_total(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ $order = $this->create_completed_order( $customer_id, 'EUR', 100.00 );
+ $foreign_order = $this->create_completed_order( $customer_id, 'GBP', 50.00 );
+
+ wc_create_refund(
+ array(
+ 'order_id' => $foreign_order->get_id(),
+ 'amount' => 20.00,
+ )
+ );
+
+ $rows = self::get_currency_rows( $this->render_customer_history( $order ), 'order-attribution-total-spend' );
+
+ $this->assertSame( '€100.00', $rows['EUR']['amount'] ?? null, 'A refund in another currency should leave the store currency total untouched' );
+ $this->assertSame( '£30.00', $rows['GBP']['amount'] ?? null, 'The refund should come off the total of its own currency' );
+ }
+
+ /**
+ * @testdox Should keep passing the single total and average to template overrides that only read the original arguments (HPOS).
+ */
+ public function test_legacy_template_override_receives_single_total_and_average(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ $order = $this->create_completed_order( $customer_id, 'EUR', 41.90 );
+ $this->create_completed_order( $customer_id, 'EUR', 10.00 );
+ $this->create_completed_order( $customer_id, 'GBP', 36.00 );
+
+ // A theme override written for the 10.8.0 template reads only these four variables.
+ $legacy_template = (string) wp_tempnam( 'customer-history' );
+ file_put_contents( $legacy_template, '<?php echo esc_html( "orders={$orders_count};total=" . wc_format_decimal( $total_spend, 2 ) . ";average=" . wc_format_decimal( $avg_order_value, 2 ) . ";tooltip=" . ( "" === $tooltip ? "no" : "yes" ) );' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture.
+
+ $use_legacy_template = function ( $template, $template_name ) use ( $legacy_template ) {
+ return 'order/customer-history.php' === $template_name ? $legacy_template : $template;
+ };
+ add_filter( 'wc_get_template', $use_legacy_template, 10, 2 );
+
+ try {
+ $output = $this->render_customer_history( $order );
+ } finally {
+ unlink( $legacy_template ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test fixture.
+ }
+
+ $this->assertSame( 'orders=3;total=87.90;average=29.30;tooltip=yes', $output, 'An existing template override should keep receiving the values it received before per-currency totals were added' );
+ }
+
+ /**
+ * @testdox Should render a single total when the bundled template is loaded with only the original arguments.
+ */
+ public function test_bundled_template_renders_single_total_from_original_arguments(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+
+ ob_start();
+ try {
+ wc_get_template(
+ 'order/customer-history.php',
+ array(
+ 'orders_count' => 2,
+ 'total_spend' => 100.0,
+ 'avg_order_value' => 50.0,
+ 'tooltip' => 'Tooltip text',
+ )
+ );
+ } finally {
+ $output = (string) ob_get_clean();
+ }
+
+ $this->assertSame( '2', self::get_text( $output, 'order-attribution-total-orders' ), 'Should show the order count it was given' );
+ $this->assertSame( '€100.00', self::get_text( $output, 'order-attribution-total-spend' ), 'Should show the single total it was given' );
+ $this->assertSame( '€50.00', self::get_text( $output, 'order-attribution-average-order-value' ), 'Should show the single average it was given' );
+ $this->assertStringNotContainsString( 'data-currency', $output, 'Should not list currencies it was not given' );
+ }
+
+ /**
+ * @testdox Should count orders whose stored currency is missing, or is the store currency in another letter case or with spaces, towards the store currency total (HPOS).
+ *
+ * @testWith [null]
+ * [""]
+ * ["eur"]
+ * ["EUR "]
+ *
+ * @param string|null $stored_currency The currency value stored on one of the orders.
+ */
+ public function test_orders_with_a_missing_or_loosely_stored_store_currency_are_totalled_together( ?string $stored_currency ): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ // The loosely stored order comes first, so a database that groups it with the clean one reports its spelling.
+ $this->overwrite_stored_currency( $this->create_completed_order( $customer_id, 'EUR', 10.00 ), $stored_currency );
+ $order = $this->create_completed_order( $customer_id, 'EUR', 40.00 );
+ $this->create_completed_order( $customer_id, 'GBP', 36.00 );
+
+ $output = $this->render_customer_history( $order );
+
+ $this->assertSame( '3', self::get_text( $output, 'order-attribution-total-orders' ), 'Every order should still be counted' );
+ $this->assertSame(
+ array(
+ 'EUR' => array(
+ 'amount' => '€50.00',
+ 'code' => 'EUR',
+ 'orders' => '(2 orders)',
+ ),
+ 'GBP' => array(
+ 'amount' => '£36.00',
+ 'code' => 'GBP',
+ 'orders' => '(1 order)',
+ ),
+ ),
+ self::get_currency_rows( $output, 'order-attribution-total-spend' ),
+ 'The order should be added to the store currency total instead of being dropped or listed on its own'
+ );
+ }
+
+ /**
+ * @testdox Should count orders in a currency code that is not three letters long (HPOS).
+ */
+ public function test_orders_in_a_four_letter_currency_code_are_counted(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ $order = $this->create_completed_order( $customer_id, 'EUR', 40.00 );
+ // Extensions can register codes of up to 10 characters. The registered list is cached
+ // for the request, so the code is written straight to the order instead.
+ $this->overwrite_stored_currency( $this->create_completed_order( $customer_id, 'EUR', 25.00 ), 'USDT' );
+
+ $output = $this->render_customer_history( $order );
+
+ $this->assertSame( '2', self::get_text( $output, 'order-attribution-total-orders' ), 'An order in a four-letter currency should still be counted' );
+ $this->assertSame(
+ array(
+ 'EUR' => array(
+ 'amount' => '€40.00',
+ 'code' => 'EUR',
+ 'orders' => '(1 order)',
+ ),
+ 'USDT' => array(
+ 'amount' => '25.00',
+ 'code' => 'USDT',
+ 'orders' => '(1 order)',
+ ),
+ ),
+ self::get_currency_rows( $output, 'order-attribution-total-spend' ),
+ 'An order in a four-letter currency should get its own total'
+ );
+ }
+
+ /**
+ * @testdox Should show the total and average in the order currency when all of a customer's orders are in a currency other than the store currency (HPOS).
+ */
+ public function test_customer_with_only_a_foreign_currency_shows_that_currency(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ $order = $this->create_completed_order( $customer_id, 'GBP', 36.00 );
+
+ $output = $this->render_customer_history( $order );
+ $expected_row = array(
+ 'GBP' => array(
+ 'amount' => '£36.00',
+ 'code' => 'GBP',
+ 'orders' => '',
+ ),
+ );
+
+ $this->assertSame( $expected_row, self::get_currency_rows( $output, 'order-attribution-total-spend' ), 'The total should be shown in the order currency and labelled with its code' );
+ $this->assertSame( $expected_row, self::get_currency_rows( $output, 'order-attribution-average-order-value' ), 'The average should be shown in the order currency and labelled with its code' );
+ $this->assertStringNotContainsString( 'Orders in different currencies are totaled separately.', $output, 'A customer with one currency should keep the original tooltip' );
+ }
+
+ /**
+ * @testdox Should keep the single-total markup for a customer whose orders are all in the store currency (HPOS).
+ */
+ public function test_store_currency_customer_keeps_single_total_markup(): void {
+ update_option( 'woocommerce_currency', 'EUR' );
+ $customer_id = $this->factory->user->create();
+
+ $order = $this->create_completed_order( $customer_id, 'EUR', 25.00 );
+
+ $output = $this->render_customer_history( $order );
+ $xpath = self::get_xpath( $output );
+
+ $children = array();
+ foreach ( $xpath->query( '//div[' . self::has_class( 'customer-history' ) . ']/*' ) as $child ) {
+ $children[] = trim( $xpath->evaluate( 'name()', $child ) . '.' . $child->getAttribute( 'class' ), '.' );
+ }
+
+ $this->assertSame(
+ array(
+ 'h4',
+ 'span.order-attribution-total-orders',
+ 'h4',
+ 'span.order-attribution-total-spend',
+ 'h4',
+ 'span.order-attribution-average-order-value',
+ ),
+ $children,
+ 'Markup for a customer with only store currency orders should stay as it was before per-currency totals were added'
+ );
+ $this->assertSame( '€25.00', self::get_text( $output, 'order-attribution-total-spend' ), 'Should show the single total without a currency code' );
+ $this->assertStringNotContainsString( 'data-currency', $output, 'A customer with only store currency orders should not get per-currency rows' );
+ $this->assertStringNotContainsString( 'Orders in different currencies are totaled separately.', $output, 'A customer with one currency should keep the original tooltip' );
+ }
+
+ /**
+ * Creates a completed order with the given currency and total.
+ *
+ * @param int $customer_id The customer user ID, or 0 for a guest.
+ * @param string $currency The order currency code.
+ * @param float $total The order total.
+ * @param string $billing_email Optional billing email, which is what guest orders are matched on.
+ * @return WC_Order The saved order.
+ */
+ private function create_completed_order( int $customer_id, string $currency, float $total, string $billing_email = '' ): WC_Order {
+ $order = WC_Helper_Order::create_order( $customer_id );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->set_currency( $currency );
+ $order->set_total( $total );
+ if ( '' !== $billing_email ) {
+ $order->set_billing_email( $billing_email );
+ }
+ $order->save();
+
+ return $order;
+ }
+
+ /**
+ * Replaces the currency stored for an order, bypassing the validation in WC_Order::set_currency().
+ *
+ * @param WC_Order $order The order to change.
+ * @param string|null $currency The value to store in the currency column.
+ */
+ private function overwrite_stored_currency( WC_Order $order, ?string $currency ): void {
+ global $wpdb;
+
+ $wpdb->update( OrdersTableDataStore::get_orders_table_name(), array( 'currency' => $currency ), array( 'id' => $order->get_id() ) );
+ }
+
+ /**
+ * Renders the customer history meta box for an order.
+ *
+ * @param WC_Order $order The order being viewed.
+ * @return string The rendered HTML.
+ */
+ private function render_customer_history( WC_Order $order ): string {
+ ob_start();
+ try {
+ $this->sut->output( $order );
+ } finally {
+ $output = (string) ob_get_clean();
+ }
+
+ return $output;
+ }
+
+ /**
+ * Returns the visible text of the first element with the given class.
+ *
+ * @param string $output The rendered meta box HTML.
+ * @param string $element_class The class of the element to read.
+ * @return string The element text with surrounding and repeated whitespace removed.
+ */
+ private static function get_text( string $output, string $element_class ): string {
+ return self::get_xpath( $output )->evaluate( 'normalize-space(//*[' . self::has_class( $element_class ) . '])' );
+ }
+
+ /**
+ * Returns the per-currency rows that hold an amount with the given class, keyed by currency code.
+ *
+ * @param string $output The rendered meta box HTML.
+ * @param string $amount_class The class of the amount element, e.g. 'order-attribution-total-spend'.
+ * @return array<string, array{amount: string, code: string, orders: string}> Visible text of each row, in display order.
+ */
+ private static function get_currency_rows( string $output, string $amount_class ): array {
+ $xpath = self::get_xpath( $output );
+ $rows = array();
+
+ foreach ( $xpath->query( '//*[@data-currency][.//*[' . self::has_class( $amount_class ) . ']]' ) as $row ) {
+ $rows[ $row->getAttribute( 'data-currency' ) ] = array(
+ 'amount' => $xpath->evaluate( 'normalize-space(.//*[' . self::has_class( $amount_class ) . '])', $row ),
+ 'code' => $xpath->evaluate( 'normalize-space(.//*[' . self::has_class( 'order-attribution-currency-code' ) . '])', $row ),
+ 'orders' => $xpath->evaluate( 'normalize-space(.//*[' . self::has_class( 'order-attribution-currency-orders' ) . '])', $row ),
+ );
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Returns an XPath condition that matches elements carrying the given class.
+ *
+ * @param string $element_class The class to match.
+ * @return string The XPath condition.
+ */
+ private static function has_class( string $element_class ): string {
+ return 'contains(concat(" ", normalize-space(@class), " "), " ' . $element_class . ' ")';
+ }
+
+ /**
+ * Parses rendered meta box HTML for XPath queries.
+ *
+ * @param string $output The rendered meta box HTML.
+ * @return DOMXPath XPath for the parsed HTML.
+ */
+ private static function get_xpath( string $output ): DOMXPath {
+ $document = new DOMDocument();
+ $previous_state = libxml_use_internal_errors( true );
+ $document->loadHTML( '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $output . '</body></html>' );
+ libxml_clear_errors();
+ libxml_use_internal_errors( $previous_state );
+
+ return new DOMXPath( $document );
+ }
+
/**
* @testdox Tooltip should list default excluded statuses (pending payment, failed, cancelled).
*/
@@ -707,6 +1097,28 @@ 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 keep showing a single total, because the analytics tables hold no per-currency totals.
+ */
+ public function test_cpt_fallback_keeps_single_total(): void {
+ $this->use_cpt_orders();
+ update_option( 'woocommerce_currency', 'EUR' );
+
+ \WC_Helper_Reports::reset_stats_dbs();
+ AdminOrder::add_filters();
+
+ $customer_id = $this->factory->user->create();
+ $order = $this->create_completed_order( $customer_id, 'EUR', 100.00 );
+ OrdersStatsDataStore::sync_order( $order->get_id() );
+
+ $output = $this->render_customer_history( wc_get_order( $order->get_id() ) );
+
+ $this->assertSame( '1', self::get_text( $output, 'order-attribution-total-orders' ), 'Should show the order count from analytics data' );
+ $this->assertSame( '€100.00', self::get_text( $output, 'order-attribution-total-spend' ), 'Should show the single total from analytics data' );
+ $this->assertSame( '€100.00', self::get_text( $output, 'order-attribution-average-order-value' ), 'Should show the single average from analytics data' );
+ $this->assertStringNotContainsString( 'data-currency', $output, 'Legacy order storage should not get per-currency rows' );
+ }
+
/**
* @testdox CPT fallback should render customer history from a base WC_Order without logging warnings.
*/