From 0a787f9ce1286d77795c7131a8ef2b1d765ed1f5 Mon Sep 17 00:00:00 2001 From: Foteini Giannaropoulou Date: Thu, 11 Jun 2026 13:11:51 +0300 Subject: [PATCH 1/4] Sync WooCommerce module: Start syncing 'woocommerce_customer_object_updated_props' actions --- ...dd-woocommerce-customer-updated-props-sync | 4 + .../sync/src/modules/class-woocommerce.php | 76 +++++++++++++++++++ .../sync/Jetpack_Sync_WooCommerce_Test.php | 33 ++++++++ 3 files changed, 113 insertions(+) create mode 100644 projects/packages/sync/changelog/add-woocommerce-customer-updated-props-sync diff --git a/projects/packages/sync/changelog/add-woocommerce-customer-updated-props-sync b/projects/packages/sync/changelog/add-woocommerce-customer-updated-props-sync new file mode 100644 index 000000000000..b21419361ece --- /dev/null +++ b/projects/packages/sync/changelog/add-woocommerce-customer-updated-props-sync @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Sync: Send WooCommerce customer account detail updates. diff --git a/projects/packages/sync/src/modules/class-woocommerce.php b/projects/packages/sync/src/modules/class-woocommerce.php index fd30b4291f15..22dea89c787b 100644 --- a/projects/packages/sync/src/modules/class-woocommerce.php +++ b/projects/packages/sync/src/modules/class-woocommerce.php @@ -129,6 +129,7 @@ public function __construct() { add_filter( 'jetpack_sync_comment_meta_whitelist', array( $this, 'add_woocommerce_comment_meta_whitelist' ), 10 ); add_filter( 'jetpack_sync_before_enqueue_woocommerce_new_order_item', array( $this, 'filter_order_item' ) ); + add_filter( 'jetpack_sync_before_enqueue_woocommerce_customer_object_updated_props', array( $this, 'filter_customer_updated_props' ) ); add_filter( 'jetpack_sync_whitelisted_comment_types', array( $this, 'add_review_comment_types' ) ); // Blacklist Action Scheduler comment types. @@ -194,6 +195,9 @@ public function init_listeners( $callable ) { add_action( 'woocommerce_new_webhook', $callable, 10, 1 ); add_action( 'woocommerce_webhook_deleted', $callable, 10, 2 ); add_action( 'woocommerce_webhook_updated', $callable, 10, 1 ); + + // Customers. + add_action( 'woocommerce_customer_object_updated_props', $callable, 10, 2 ); } /** @@ -242,6 +246,78 @@ public function filter_order_item( $args ) { return $args; } + /** + * Replace the customer object with a minimal user object before enqueueing. + * + * @param array $args Hook arguments. + * @return array|false Minimal user object and changed prop names, or false when invalid. + */ + public function filter_customer_updated_props( $args ) { + if ( + ! is_array( $args ) + || ! isset( $args[0] ) + || ! isset( $args[1] ) + || ! is_object( $args[0] ) + || ! is_callable( array( $args[0], 'get_id' ) ) + || ! is_array( $args[1] ) + ) { + return false; + } + + $customer_id = (int) $args[0]->get_id(); + if ( $customer_id <= 0 ) { + return false; + } + + $updated_props = array(); + foreach ( $args[1] as $prop ) { + if ( ! is_string( $prop ) && ! is_numeric( $prop ) ) { + continue; + } + + $prop = sanitize_key( (string) $prop ); + if ( '' === $prop ) { + continue; + } + + $updated_props[] = $prop; + } + + $updated_props = array_values( array_unique( $updated_props ) ); + if ( empty( $updated_props ) ) { + return false; + } + + return array( $this->build_minimal_customer_user_object( $customer_id ), $updated_props ); + } + + /** + * Build a minimal WP_User-shaped object for Activity Log. + * + * @param int $customer_id Customer user ID. + * @return object Minimal user object. + */ + private function build_minimal_customer_user_object( $customer_id ) { + $user_data = (object) array( + 'ID' => $customer_id, + 'display_name' => '', + 'user_login' => '', + 'user_email' => '', + ); + + $user = get_userdata( $customer_id ); + if ( $user ) { + $user_data->display_name = (string) $user->display_name; + $user_data->user_login = (string) $user->user_login; + $user_data->user_email = (string) $user->user_email; + } + + return (object) array( + 'ID' => $customer_id, + 'data' => $user_data, + ); + } + /** * Handler for filtering out non-whitelisted order item meta. * diff --git a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php index d8635390558d..8168b0ce581e 100644 --- a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php +++ b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php @@ -152,6 +152,39 @@ function ( $event ) { $this->assertEmpty( $foo_events ); } + public function test_customer_updated_props_are_synced_without_customer_data() { + $user_id = wp_insert_user( + array( + 'user_login' => 'test_customer', + 'user_email' => 'customer@example.com', + 'user_pass' => 'test', + ) + ); + $customer = new WC_Customer( $user_id ); + + $customer->set_billing_email( 'updated@example.com' ); + $customer->set_billing_city( 'San Francisco' ); + $customer->save(); + + $this->sender->do_sync(); + + $customer_updated_event = $this->server_event_storage->get_most_recent_event( 'woocommerce_customer_object_updated_props' ); + + $this->assertTrue( (bool) $customer_updated_event ); + $this->assertIsObject( $customer_updated_event->args[0] ); + $this->assertNotInstanceOf( 'WC_Customer', $customer_updated_event->args[0] ); + $this->assertEquals( $user_id, $customer_updated_event->args[0]->ID ); + $this->assertEquals( $user_id, $customer_updated_event->args[0]->data->ID ); + $this->assertEquals( 'test_customer', $customer_updated_event->args[0]->data->user_login ); + $this->assertEquals( 'customer@example.com', $customer_updated_event->args[0]->data->user_email ); + $this->assertContains( 'billing_email', $customer_updated_event->args[1] ); + $this->assertContains( 'billing_city', $customer_updated_event->args[1] ); + + $encoded_event_args = wp_json_encode( $customer_updated_event->args, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); + $this->assertStringNotContainsString( 'updated@example.com', $encoded_event_args ); + $this->assertStringNotContainsString( 'San Francisco', $encoded_event_args ); + } + public function test_approving_a_review_is_synced() { $post_id = self::factory()->post->create(); $review_ids = self::factory()->comment->create_post_comments( From a1aa3cdcbf1df8749b56b8e8ba7b7d9fa516fded Mon Sep 17 00:00:00 2001 From: Foteini Giannaropoulou Date: Thu, 11 Jun 2026 13:12:29 +0300 Subject: [PATCH 2/4] changelog --- .../jetpack/changelog/add-sync-woo-customer-props-updated | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/add-sync-woo-customer-props-updated diff --git a/projects/plugins/jetpack/changelog/add-sync-woo-customer-props-updated b/projects/plugins/jetpack/changelog/add-sync-woo-customer-props-updated new file mode 100644 index 000000000000..916cb15ef262 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-sync-woo-customer-props-updated @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Updated Sync related unit tests + + From 3c1e46bf934a4158dad1dc75aa85523210a866c4 Mon Sep 17 00:00:00 2001 From: Foteini Giannaropoulou Date: Fri, 12 Jun 2026 10:13:55 +0300 Subject: [PATCH 3/4] Alternate approach to ensure all meta changes are picked --- .../sync/src/modules/class-woocommerce.php | 146 ++++++++++++++++-- .../sync/Jetpack_Sync_WooCommerce_Test.php | 71 +++++++-- 2 files changed, 189 insertions(+), 28 deletions(-) diff --git a/projects/packages/sync/src/modules/class-woocommerce.php b/projects/packages/sync/src/modules/class-woocommerce.php index 22dea89c787b..2659a30887c7 100644 --- a/projects/packages/sync/src/modules/class-woocommerce.php +++ b/projects/packages/sync/src/modules/class-woocommerce.php @@ -55,6 +55,38 @@ class WooCommerce extends Module { 'discount_amount_tax', ); + /** + * Mapping between WooCommerce customer detail user meta keys and customer prop names. + * + * @access private + * + * @var array + */ + private static $customer_detail_meta_key_to_prop = array( + 'paying_customer' => 'is_paying_customer', + 'billing_first_name' => 'billing_first_name', + 'billing_last_name' => 'billing_last_name', + 'billing_company' => 'billing_company', + 'billing_address_1' => 'billing_address_1', + 'billing_address_2' => 'billing_address_2', + 'billing_city' => 'billing_city', + 'billing_state' => 'billing_state', + 'billing_postcode' => 'billing_postcode', + 'billing_country' => 'billing_country', + 'billing_email' => 'billing_email', + 'billing_phone' => 'billing_phone', + 'shipping_first_name' => 'shipping_first_name', + 'shipping_last_name' => 'shipping_last_name', + 'shipping_company' => 'shipping_company', + 'shipping_address_1' => 'shipping_address_1', + 'shipping_address_2' => 'shipping_address_2', + 'shipping_city' => 'shipping_city', + 'shipping_state' => 'shipping_state', + 'shipping_postcode' => 'shipping_postcode', + 'shipping_country' => 'shipping_country', + 'shipping_phone' => 'shipping_phone', + ); + /** * Name of the order item database table. * @@ -64,6 +96,13 @@ class WooCommerce extends Module { */ private $order_item_table_name; + /** + * Customer detail meta changes to sync at the end of the request. + * + * @var array + */ + private $customer_meta_updates = array(); + /** * The table name. * @@ -129,7 +168,7 @@ public function __construct() { add_filter( 'jetpack_sync_comment_meta_whitelist', array( $this, 'add_woocommerce_comment_meta_whitelist' ), 10 ); add_filter( 'jetpack_sync_before_enqueue_woocommerce_new_order_item', array( $this, 'filter_order_item' ) ); - add_filter( 'jetpack_sync_before_enqueue_woocommerce_customer_object_updated_props', array( $this, 'filter_customer_updated_props' ) ); + add_filter( 'jetpack_sync_before_enqueue_jetpack_updated_woo_customer_meta', array( $this, 'filter_customer_updated_meta' ) ); add_filter( 'jetpack_sync_whitelisted_comment_types', array( $this, 'add_review_comment_types' ) ); // Blacklist Action Scheduler comment types. @@ -197,7 +236,11 @@ public function init_listeners( $callable ) { add_action( 'woocommerce_webhook_updated', $callable, 10, 1 ); // Customers. - add_action( 'woocommerce_customer_object_updated_props', $callable, 10, 2 ); + add_action( 'added_user_meta', array( $this, 'maybe_sync_customer_meta_update' ), 10, 4 ); + add_action( 'updated_user_meta', array( $this, 'maybe_sync_customer_meta_update' ), 10, 4 ); + add_action( 'deleted_user_meta', array( $this, 'maybe_sync_customer_meta_update' ), 10, 4 ); + add_action( 'shutdown', array( $this, 'action_customer_meta_updates' ) ); + add_action( 'jetpack_updated_woo_customer_meta', $callable, 10, 2 ); } /** @@ -247,48 +290,121 @@ public function filter_order_item( $args ) { } /** - * Replace the customer object with a minimal user object before enqueueing. + * Validate the minimal customer meta update payload before enqueueing. * * @param array $args Hook arguments. * @return array|false Minimal user object and changed prop names, or false when invalid. */ - public function filter_customer_updated_props( $args ) { + public function filter_customer_updated_meta( $args ) { if ( ! is_array( $args ) || ! isset( $args[0] ) || ! isset( $args[1] ) || ! is_object( $args[0] ) - || ! is_callable( array( $args[0], 'get_id' ) ) + || ! isset( $args[0]->data ) + || ! is_object( $args[0]->data ) + || ! isset( $args[0]->data->ID ) + || ! is_numeric( $args[0]->data->ID ) || ! is_array( $args[1] ) ) { return false; } - $customer_id = (int) $args[0]->get_id(); + $customer_id = (int) $args[0]->data->ID; if ( $customer_id <= 0 ) { return false; } + $args[0]->ID = $customer_id; + $args[0]->data->ID = $customer_id; + + $updated_props = $this->get_customer_detail_props( $args[1] ); + if ( empty( $updated_props ) ) { + return false; + } + + return array( $args[0], $updated_props ); + } + + /** + * Track updated WooCommerce customer meta props for syncing. + * + * @param int $meta_id ID of the meta object. + * @param int $user_id User ID. + * @param string $meta_key Meta key. + * @param mixed $value Meta value. + */ + public function maybe_sync_customer_meta_update( $meta_id, $user_id, $meta_key, $value ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $customer_id = (int) $user_id; + if ( $customer_id <= 0 ) { + return; + } + + $updated_props = $this->get_customer_detail_props( array( $meta_key ) ); + if ( empty( $updated_props ) ) { + return; + } + + if ( ! isset( $this->customer_meta_updates[ $customer_id ] ) ) { + $this->customer_meta_updates[ $customer_id ] = array(); + } + + foreach ( $updated_props as $prop ) { + $this->customer_meta_updates[ $customer_id ][ $prop ] = true; + } + } + + /** + * Send batched WooCommerce customer meta updates. + */ + public function action_customer_meta_updates() { + if ( empty( $this->customer_meta_updates ) ) { + return; + } + + $customer_meta_updates = $this->customer_meta_updates; + $this->customer_meta_updates = array(); + + foreach ( $customer_meta_updates as $customer_id => $updated_props ) { + /** + * Fires when WooCommerce customer details stored in user meta are updated. + * + * @param object $customer Minimal WP_User-shaped customer object. + * @param array $updated_props Updated customer detail prop names. + */ + do_action( + 'jetpack_updated_woo_customer_meta', + $this->build_minimal_customer_user_object( (int) $customer_id ), + array_keys( $updated_props ) + ); + } + } + + /** + * Retrieve whitelisted WooCommerce customer detail props. + * + * @param array $props Customer detail meta keys or prop names. + * @return array Customer detail prop names. + */ + private function get_customer_detail_props( $props ) { $updated_props = array(); - foreach ( $args[1] as $prop ) { + foreach ( $props as $prop ) { if ( ! is_string( $prop ) && ! is_numeric( $prop ) ) { continue; } $prop = sanitize_key( (string) $prop ); - if ( '' === $prop ) { + if ( isset( self::$customer_detail_meta_key_to_prop[ $prop ] ) ) { + $updated_props[] = self::$customer_detail_meta_key_to_prop[ $prop ]; continue; } - $updated_props[] = $prop; - } - - $updated_props = array_values( array_unique( $updated_props ) ); - if ( empty( $updated_props ) ) { - return false; + if ( in_array( $prop, self::$customer_detail_meta_key_to_prop, true ) ) { + $updated_props[] = $prop; + } } - return array( $this->build_minimal_customer_user_object( $customer_id ), $updated_props ); + return array_values( array_unique( $updated_props ) ); } /** diff --git a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php index 8168b0ce581e..91cdb8a1ebd0 100644 --- a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php +++ b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php @@ -1,6 +1,7 @@ assertEmpty( $foo_events ); } - public function test_customer_updated_props_are_synced_without_customer_data() { - $user_id = wp_insert_user( - array( - 'user_login' => 'test_customer', - 'user_email' => 'customer@example.com', - 'user_pass' => 'test', - ) - ); - $customer = new WC_Customer( $user_id ); + public function test_customer_meta_updates_are_synced_without_customer_data() { + $user_id = $this->create_test_customer_user( 'test_customer', 'customer@example.com' ); - $customer->set_billing_email( 'updated@example.com' ); - $customer->set_billing_city( 'San Francisco' ); - $customer->save(); + update_user_meta( $user_id, 'billing_email', 'updated@example.com' ); + update_user_meta( $user_id, 'billing_city', 'San Francisco' ); + update_user_meta( $user_id, 'paying_customer', '1' ); + $this->flush_customer_meta_updates(); $this->sender->do_sync(); - $customer_updated_event = $this->server_event_storage->get_most_recent_event( 'woocommerce_customer_object_updated_props' ); + $customer_updated_event = $this->server_event_storage->get_most_recent_event( 'jetpack_updated_woo_customer_meta' ); $this->assertTrue( (bool) $customer_updated_event ); $this->assertIsObject( $customer_updated_event->args[0] ); @@ -179,12 +174,62 @@ public function test_customer_updated_props_are_synced_without_customer_data() { $this->assertEquals( 'customer@example.com', $customer_updated_event->args[0]->data->user_email ); $this->assertContains( 'billing_email', $customer_updated_event->args[1] ); $this->assertContains( 'billing_city', $customer_updated_event->args[1] ); + $this->assertContains( 'is_paying_customer', $customer_updated_event->args[1] ); + $this->assertNotContains( 'paying_customer', $customer_updated_event->args[1] ); $encoded_event_args = wp_json_encode( $customer_updated_event->args, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ); $this->assertStringNotContainsString( 'updated@example.com', $encoded_event_args ); $this->assertStringNotContainsString( 'San Francisco', $encoded_event_args ); } + public function test_non_customer_meta_updates_are_not_synced_as_customer_details() { + $user_id = $this->create_test_customer_user( 'test_customer_untracked_meta', 'untracked-customer@example.com' ); + + update_user_meta( $user_id, 'session_tokens', 'secret' ); + update_user_meta( $user_id, 'first_name', 'Ada' ); + update_user_meta( $user_id, 'last_name', 'Lovelace' ); + + $this->flush_customer_meta_updates(); + $this->sender->do_sync(); + + $this->assertFalse( (bool) $this->server_event_storage->get_most_recent_event( 'jetpack_updated_woo_customer_meta' ) ); + } + + /** + * Create a customer user for WooCommerce sync tests. + * + * @param string $user_login User login. + * @param string $user_email User email. + * @return int User ID. + */ + private function create_test_customer_user( $user_login, $user_email ) { + $user_id = wp_insert_user( + array( + 'user_login' => $user_login, + 'user_email' => $user_email, + 'user_pass' => 'test', + ) + ); + + if ( is_wp_error( $user_id ) ) { + $this->fail( $user_id->get_error_message() ); + } + + return (int) $user_id; + } + + /** + * Flush pending customer meta updates. + */ + private function flush_customer_meta_updates() { + $woocommerce_module = Modules::get_module( 'woocommerce' ); + if ( ! $woocommerce_module instanceof WooCommerce_Module ) { + $this->fail( 'WooCommerce sync module is not available.' ); + } + + $woocommerce_module->action_customer_meta_updates(); + } + public function test_approving_a_review_is_synced() { $post_id = self::factory()->post->create(); $review_ids = self::factory()->comment->create_post_comments( From 5ab8fcf7215b1b890f51c80e8545782971909d30 Mon Sep 17 00:00:00 2001 From: Foteini Giannaropoulou Date: Fri, 12 Jun 2026 11:55:11 +0300 Subject: [PATCH 4/4] Enforce minimal WP_User shaped payload and update docblocks --- .../sync/src/modules/class-woocommerce.php | 13 ++--- .../sync/Jetpack_Sync_WooCommerce_Test.php | 53 +++++++++++++++++-- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/projects/packages/sync/src/modules/class-woocommerce.php b/projects/packages/sync/src/modules/class-woocommerce.php index 2659a30887c7..a738419e445c 100644 --- a/projects/packages/sync/src/modules/class-woocommerce.php +++ b/projects/packages/sync/src/modules/class-woocommerce.php @@ -315,24 +315,21 @@ public function filter_customer_updated_meta( $args ) { return false; } - $args[0]->ID = $customer_id; - $args[0]->data->ID = $customer_id; - $updated_props = $this->get_customer_detail_props( $args[1] ); if ( empty( $updated_props ) ) { return false; } - return array( $args[0], $updated_props ); + return array( $this->build_minimal_customer_user_object( $customer_id ), $updated_props ); } /** * Track updated WooCommerce customer meta props for syncing. * - * @param int $meta_id ID of the meta object. - * @param int $user_id User ID. - * @param string $meta_key Meta key. - * @param mixed $value Meta value. + * @param int|array $meta_id ID of the meta object, or IDs for deleted meta. + * @param int $user_id User ID. + * @param string $meta_key Meta key. + * @param mixed $value Meta value. */ public function maybe_sync_customer_meta_update( $meta_id, $user_id, $meta_key, $value ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable $customer_id = (int) $user_id; diff --git a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php index 91cdb8a1ebd0..7c2660f7261e 100644 --- a/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php +++ b/projects/plugins/jetpack/tests/php/sync/Jetpack_Sync_WooCommerce_Test.php @@ -195,6 +195,44 @@ public function test_non_customer_meta_updates_are_not_synced_as_customer_detail $this->assertFalse( (bool) $this->server_event_storage->get_most_recent_event( 'jetpack_updated_woo_customer_meta' ) ); } + public function test_customer_meta_filter_rebuilds_minimal_customer_object() { + $user_id = $this->create_test_customer_user( 'test_customer_extra_data', 'extra-customer@example.com' ); + $woocommerce_module = $this->get_woocommerce_module(); + + $filtered_args = $woocommerce_module->filter_customer_updated_meta( + array( + (object) array( + 'ID' => $user_id, + 'extra_top_level' => 'secret', + 'data' => (object) array( + 'ID' => $user_id, + 'user_login' => 'injected_login', + 'user_email' => 'injected@example.com', + 'billing_address_1' => '123 Secret St', + ), + ), + array( 'billing_email' ), + ) + ); + + if ( ! is_array( $filtered_args ) ) { + $this->fail( 'Customer meta filter returned an invalid payload.' ); + } + + $customer = $filtered_args[0]; + if ( ! is_object( $customer ) || ! isset( $customer->data ) || ! is_object( $customer->data ) ) { + $this->fail( 'Customer meta filter did not return a minimal customer object.' ); + } + + $this->assertEquals( $user_id, $customer->ID ); + $this->assertEquals( $user_id, $customer->data->ID ); + $this->assertEquals( 'test_customer_extra_data', $customer->data->user_login ); + $this->assertEquals( 'extra-customer@example.com', $customer->data->user_email ); + $this->assertObjectNotHasProperty( 'extra_top_level', $customer ); + $this->assertObjectNotHasProperty( 'billing_address_1', $customer->data ); + $this->assertSame( array( 'billing_email' ), $filtered_args[1] ); + } + /** * Create a customer user for WooCommerce sync tests. * @@ -219,15 +257,24 @@ private function create_test_customer_user( $user_login, $user_email ) { } /** - * Flush pending customer meta updates. + * Retrieve the WooCommerce sync module. + * + * @return WooCommerce_Module WooCommerce sync module. */ - private function flush_customer_meta_updates() { + private function get_woocommerce_module() { $woocommerce_module = Modules::get_module( 'woocommerce' ); if ( ! $woocommerce_module instanceof WooCommerce_Module ) { $this->fail( 'WooCommerce sync module is not available.' ); } - $woocommerce_module->action_customer_meta_updates(); + return $woocommerce_module; + } + + /** + * Flush pending customer meta updates. + */ + private function flush_customer_meta_updates() { + $this->get_woocommerce_module()->action_customer_meta_updates(); } public function test_approving_a_review_is_synced() {