Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Sync: Send WooCommerce customer account detail updates.
189 changes: 189 additions & 0 deletions projects/packages/sync/src/modules/class-woocommerce.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 );
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Significance: patch
Type: other
Comment: Updated Sync related unit tests


Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Automattic\Jetpack\Sync\Modules;
use Automattic\Jetpack\Sync\Modules\WooCommerce as WooCommerce_Module;
use PHPUnit\Framework\Attributes\Group;

require_once __DIR__ . '/Jetpack_Sync_TestBase.php';
Expand Down Expand Up @@ -152,6 +153,130 @@ function ( $event ) {
$this->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(
Expand Down
Loading