Commit c34ab475735 for woocommerce
commit c34ab475735245b9cfd02a4ac6551ae4c7cbfd39
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Aug 14 14:24:34 2026 +0200
Add billing and shipping phone to the Analytics Customers report (#67641)
* Add billing and shipping phone columns to wc_customer_lookup and sync them from orders and customer profiles
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163LNUNYYxAkxr89theBc4P
* Expose customer phone numbers in the Customers report REST API and CSV export
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163LNUNYYxAkxr89theBc4P
* Add hidden-by-default billing and shipping phone columns to the Analytics Customers table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163LNUNYYxAkxr89theBc4P
* Cover customer phone sync in the customers report PHP and JS tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163LNUNYYxAkxr89theBc4P
* Add changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163LNUNYYxAkxr89theBc4P
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/32209-add-phone-to-customer-reports b/plugins/woocommerce/changelog/32209-add-phone-to-customer-reports
new file mode 100644
index 00000000000..6624c9c7f3d
--- /dev/null
+++ b/plugins/woocommerce/changelog/32209-add-phone-to-customer-reports
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add billing and shipping phone columns to the Analytics Customers report, its REST API, and CSV export.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
index 14647530251..0cf14694016 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
@@ -105,6 +105,16 @@ function CustomersReportTable( {
hiddenByDefault: true,
isSortable: true,
},
+ {
+ label: __( 'Billing phone', 'woocommerce' ),
+ key: 'billing_phone',
+ hiddenByDefault: true,
+ },
+ {
+ label: __( 'Shipping phone', 'woocommerce' ),
+ key: 'shipping_phone',
+ hiddenByDefault: true,
+ },
];
};
@@ -139,6 +149,8 @@ function CustomersReportTable( {
city,
state,
country,
+ billing_phone: billingPhone,
+ shipping_phone: shippingPhone,
} = customer;
const countryName = getCountryName( country );
const customerName =
@@ -233,6 +245,14 @@ function CustomersReportTable( {
display: postcode,
value: postcode,
},
+ {
+ display: billingPhone,
+ value: billingPhone,
+ },
+ {
+ display: shippingPhone,
+ value: shippingPhone,
+ },
];
} );
};
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
index defa68379e8..2178fb6b124 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
@@ -9,7 +9,7 @@ import { useSelect } from '@wordpress/data';
*/
import CustomersReportTable from '../table';
-const captured = { getRowsContent: null };
+const captured = { getRowsContent: null, getHeadersContent: null };
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
@@ -49,6 +49,7 @@ jest.mock( '../../../components/report-table', () => ( {
__esModule: true,
default: ( props ) => {
captured.getRowsContent = props.getRowsContent;
+ captured.getHeadersContent = props.getHeadersContent;
return null;
},
} ) );
@@ -158,3 +159,40 @@ describe( 'CustomersReportTable country cell', () => {
expect( () => renderCellDisplay( cell.display ) ).not.toThrow();
} );
} );
+
+describe( 'CustomersReportTable phone cells', () => {
+ // Phone cells are the last two columns per getHeadersContent in table.js.
+ const BILLING_PHONE_COL = 12;
+ const SHIPPING_PHONE_COL = 13;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'maps billing and shipping phone into their cells', () => {
+ mockCountriesStore( [] );
+ captured.getRowsContent = null;
+ render( <CustomersReportTable query={ {} } /> );
+ const rows = captured.getRowsContent( [
+ {
+ ...baseCustomer,
+ billing_phone: '555-32123',
+ shipping_phone: '555-99887',
+ },
+ ] );
+
+ expect( rows[ 0 ][ BILLING_PHONE_COL ].value ).toBe( '555-32123' );
+ expect( rows[ 0 ][ SHIPPING_PHONE_COL ].value ).toBe( '555-99887' );
+ } );
+
+ it( 'keeps the phone headers aligned with the phone cells', () => {
+ mockCountriesStore( [] );
+ captured.getHeadersContent = null;
+ render( <CustomersReportTable query={ {} } /> );
+ const headers = captured.getHeadersContent();
+
+ expect( headers[ BILLING_PHONE_COL ].key ).toBe( 'billing_phone' );
+ expect( headers[ SHIPPING_PHONE_COL ].key ).toBe( 'shipping_phone' );
+ expect( headers ).toHaveLength( SHIPPING_PHONE_COL + 1 );
+ } );
+} );
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b5a68b02e37..d44ffd1be5a 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -2124,6 +2124,8 @@ CREATE TABLE {$wpdb->prefix}wc_customer_lookup (
postcode varchar(20) DEFAULT '' NOT NULL,
city varchar(100) DEFAULT '' NOT NULL,
state varchar(100) DEFAULT '' NOT NULL,
+ billing_phone varchar(100) DEFAULT '' NOT NULL,
+ shipping_phone varchar(100) DEFAULT '' NOT NULL,
PRIMARY KEY (customer_id),
UNIQUE KEY user_id (user_id),
KEY email (email)
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index b4de6656e0e..a608bf67acc 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -240,7 +240,10 @@ class Controller extends GenericController implements ExportableInterface {
// Last active date is local time.
$data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false );
$data['date_last_active'] = wc_rest_prepare_date_response( $data['date_last_active'] );
- $data = $this->filter_response_by_context( $data, $context );
+ // Rows can be served from a report cache written before these columns existed.
+ $data['billing_phone'] = $data['billing_phone'] ?? '';
+ $data['shipping_phone'] = $data['shipping_phone'] ?? '';
+ $data = $this->filter_response_by_context( $data, $context );
// Wrap the data in a response object.
$response = rest_ensure_response( $data );
@@ -356,6 +359,18 @@ class Controller extends GenericController implements ExportableInterface {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
+ 'billing_phone' => array(
+ 'description' => __( 'Billing phone.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'shipping_phone' => array(
+ 'description' => __( 'Shipping phone.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
'date_registered' => array(
'description' => __( 'Date registered.', 'woocommerce' ),
'type' => 'date-time',
@@ -702,6 +717,8 @@ class Controller extends GenericController implements ExportableInterface {
'city' => __( 'City', 'woocommerce' ),
'region' => __( 'Region', 'woocommerce' ),
'postcode' => __( 'Postal Code', 'woocommerce' ),
+ 'billing_phone' => __( 'Billing Phone', 'woocommerce' ),
+ 'shipping_phone' => __( 'Shipping Phone', 'woocommerce' ),
);
/**
@@ -736,6 +753,8 @@ class Controller extends GenericController implements ExportableInterface {
'city' => $item['city'],
'region' => $item['state'],
'postcode' => $item['postcode'],
+ 'billing_phone' => $item['billing_phone'] ?? '',
+ 'shipping_phone' => $item['shipping_phone'] ?? '',
);
/**
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
index 2891d08892d..c0ff64b5e8f 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php
@@ -84,6 +84,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'city' => 'city',
'state' => 'state',
'postcode' => 'postcode',
+ 'billing_phone' => 'billing_phone',
+ 'shipping_phone' => 'shipping_phone',
'date_registered' => 'date_registered',
// Use single quotes for string literals to ensure compatibility with sql_mode=ANSI_QUOTES.
'date_last_active' => "IF( date_last_active <= '0000-00-00 00:00:00', NULL, date_last_active ) AS date_last_active",
@@ -736,6 +738,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'state' => $order->get_billing_state( 'edit' ),
'postcode' => $order->get_billing_postcode( 'edit' ),
'country' => $order->get_billing_country( 'edit' ),
+ 'billing_phone' => $order->get_billing_phone( 'edit' ),
+ 'shipping_phone' => $order->get_shipping_phone( 'edit' ),
'date_last_active' => $date_created ? gmdate( 'Y-m-d H:i:s', $date_created->getTimestamp() ) : null,
);
$format = array(
@@ -747,6 +751,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'%s',
'%s',
'%s',
+ '%s',
+ '%s',
);
// Add registered customer data.
@@ -959,6 +965,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'state' => $customer->get_billing_state( 'edit' ),
'postcode' => $customer->get_billing_postcode( 'edit' ),
'country' => $customer->get_billing_country( 'edit' ),
+ 'billing_phone' => $customer->get_billing_phone( 'edit' ),
+ 'shipping_phone' => $customer->get_shipping_phone( 'edit' ),
'date_registered' => $customer->get_date_created( 'edit' ) ? $customer->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null,
'date_last_active' => $last_active ? gmdate( 'Y-m-d H:i:s', $last_active ) : null,
);
@@ -975,6 +983,7 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
'%s',
'%s',
'%s',
+ '%s',
);
$customer_id = self::get_customer_id_by_user_id( $user_id );
@@ -1126,7 +1135,9 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
country = '',
postcode = %s,
city = %s,
- state = %s
+ state = %s,
+ billing_phone = %s,
+ shipping_phone = %s
WHERE
customer_id = %d",
array(
@@ -1137,6 +1148,8 @@ class DataStore extends ReportsDataStore implements DataStoreInterface {
$deleted_text,
$deleted_text,
$deleted_text,
+ $deleted_text,
+ $deleted_text,
$customer_id,
)
)
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index 4debbc40bac..6766d6916b7 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -83,6 +83,8 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'city', $schema );
$this->assertArrayHasKey( 'state', $schema );
$this->assertArrayHasKey( 'postcode', $schema );
+ $this->assertArrayHasKey( 'billing_phone', $schema );
+ $this->assertArrayHasKey( 'shipping_phone', $schema );
$this->assertArrayHasKey( 'date_registered', $schema );
$this->assertArrayHasKey( 'date_registered_gmt', $schema );
$this->assertArrayHasKey( 'date_last_active', $schema );
@@ -105,7 +107,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertCount( 18, $properties );
+ $this->assertCount( 20, $properties );
$this->assert_report_item_schema( $properties );
}
@@ -657,6 +659,8 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$order->set_billing_city( 'Random' );
$order->set_billing_state( 'FL' );
$order->set_billing_postcode( '54321' );
+ $order->set_billing_phone( '555-32123' );
+ $order->set_shipping_phone( '555-99887' );
$order->save();
WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
@@ -675,6 +679,116 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$this->assertTrue( 'Random' === $reports[0]['city'] );
$this->assertTrue( 'FL' === $reports[0]['state'] );
$this->assertTrue( '54321' === $reports[0]['postcode'] );
+ $this->assertTrue( '555-32123' === $reports[0]['billing_phone'] );
+ $this->assertTrue( '555-99887' === $reports[0]['shipping_phone'] );
+ }
+
+ /**
+ * @testdox Registered customer sync should populate billing and shipping phone from customer meta.
+ */
+ public function test_update_registered_customer_syncs_phone_numbers() {
+ wp_set_current_user( $this->user );
+
+ $customer = WC_Helper_Customer::create_customer( 'phonecustomer', 'password', 'phone-customer@example.com' );
+ $customer->set_billing_phone( '555-11223' );
+ $customer->set_shipping_phone( '555-44556' );
+ $customer->save();
+
+ $this->assertNotFalse( CustomersDataStore::update_registered_customer( $customer->get_id() ) );
+
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params(
+ array(
+ 'search' => 'phonecustomer',
+ 'searchby' => 'username',
+ )
+ );
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports );
+ $this->assertEquals( '555-11223', $reports[0]['billing_phone'] );
+ $this->assertEquals( '555-44556', $reports[0]['shipping_phone'] );
+ }
+
+ /**
+ * @testdox Removing order personal data should anonymize the customer's phone numbers in the lookup table.
+ */
+ public function test_anonymize_customer_erases_phone_numbers() {
+ wp_set_current_user( $this->user );
+
+ $order = WC_Helper_Order::create_order( 0 );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->set_total( 100 );
+ $order->set_billing_phone( '555-32123' );
+ $order->set_shipping_phone( '555-99887' );
+ $order->save();
+
+ WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+ // Fire the personal-data eraser hook the analytics anonymizer is attached to.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks -- the test fires an existing core hook, it does not introduce one.
+ do_action( 'woocommerce_privacy_remove_order_personal_data', $order );
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $reports );
+ $this->assertEquals( '[deleted]', $reports[0]['billing_phone'] );
+ $this->assertEquals( '[deleted]', $reports[0]['shipping_phone'] );
+ }
+
+ /**
+ * @testdox CSV export should carry the phone columns, and fall back to an empty string for rows cached before the columns existed.
+ */
+ public function test_export_includes_phone_numbers() {
+ $controller = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller();
+
+ $this->assertArrayHasKey( 'billing_phone', $controller->get_export_columns() );
+ $this->assertArrayHasKey( 'shipping_phone', $controller->get_export_columns() );
+
+ $item = array(
+ 'name' => 'Phone Customer',
+ 'username' => 'phonecustomer',
+ 'date_last_active' => null,
+ 'date_registered' => null,
+ 'email' => 'phone-customer@example.com',
+ 'orders_count' => 0,
+ 'total_spend' => 0,
+ 'avg_order_value' => 0,
+ 'country' => 'US',
+ 'city' => 'Random',
+ 'state' => 'FL',
+ 'postcode' => '54321',
+ );
+
+ $with_phones = array_merge(
+ $item,
+ array(
+ 'billing_phone' => '555-32123',
+ 'shipping_phone' => '555-99887',
+ )
+ );
+
+ $exported = $controller->prepare_item_for_export( $with_phones );
+ $this->assertEquals( '555-32123', $exported['billing_phone'] );
+ $this->assertEquals( '555-99887', $exported['shipping_phone'] );
+
+ // A row served from a report cache written before the columns existed has no phone keys at all.
+ $stale = $controller->prepare_item_for_export( $item );
+ $this->assertSame( '', $stale['billing_phone'] );
+ $this->assertSame( '', $stale['shipping_phone'] );
+
+ // The REST response must still carry the properties its schema declares.
+ $response = $controller->prepare_item_for_response( $item, new WP_REST_Request( 'GET', $this->endpoint ) );
+ $data = $response->get_data();
+ $this->assertSame( '', $data['billing_phone'] );
+ $this->assertSame( '', $data['shipping_phone'] );
}
/**