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'] );
 	}

 	/**