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 00000000000..b21419361ec --- /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 fd30b4291f1..a738419e445 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,6 +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_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. @@ -194,6 +234,13 @@ 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( '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 ); } /** @@ -242,6 +289,148 @@ public function filter_order_item( $args ) { return $args; } + /** + * 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_meta( $args ) { + if ( + ! is_array( $args ) + || ! isset( $args[0] ) + || ! isset( $args[1] ) + || ! is_object( $args[0] ) + || ! 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]->data->ID; + if ( $customer_id <= 0 ) { + return false; + } + + $updated_props = $this->get_customer_detail_props( $args[1] ); + if ( empty( $updated_props ) ) { + return false; + } + + return array( $this->build_minimal_customer_user_object( $customer_id ), $updated_props ); + } + + /** + * Track updated WooCommerce customer meta props for syncing. + * + * @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; + 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 ( $props as $prop ) { + if ( ! is_string( $prop ) && ! is_numeric( $prop ) ) { + continue; + } + + $prop = sanitize_key( (string) $prop ); + if ( isset( self::$customer_detail_meta_key_to_prop[ $prop ] ) ) { + $updated_props[] = self::$customer_detail_meta_key_to_prop[ $prop ]; + continue; + } + + if ( in_array( $prop, self::$customer_detail_meta_key_to_prop, true ) ) { + $updated_props[] = $prop; + } + } + + return array_values( array_unique( $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/changelog/add-sync-woo-customer-props-updated b/projects/plugins/jetpack/changelog/add-sync-woo-customer-props-updated new file mode 100644 index 00000000000..916cb15ef26 --- /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 + + 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 d8635390558..7c2660f7261 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_meta_updates_are_synced_without_customer_data() { + $user_id = $this->create_test_customer_user( 'test_customer', 'customer@example.com' ); + + 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( 'jetpack_updated_woo_customer_meta' ); + + $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] ); + $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' ) ); + } + + 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. + * + * @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; + } + + /** + * Retrieve the WooCommerce sync module. + * + * @return WooCommerce_Module WooCommerce sync module. + */ + 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.' ); + } + + 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() { $post_id = self::factory()->post->create(); $review_ids = self::factory()->comment->create_post_comments(