Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.64% covered (success)
94.64%
53 / 56
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync_Configuration
96.36% covered (success)
96.36%
53 / 55
77.78% covered (warning)
77.78%
7 / 9
20
0.00% covered (danger)
0.00%
0 / 1
 register
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 is_woocommerce_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 configure_sync
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 register_analytics_sync_module
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_jetpack_sync_config
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 remove_duplicate_woocommerce_analytics_module
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 expand_full_sync_config
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 skip_reports_data_before_analytics_full_sync
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 add_meta_to_sync_post_meta_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Opt-in to the shared WooCommerce Analytics sync module.
4 *
5 * @package automattic/jetpack-woocommerce-stats
6 */
7
8namespace Automattic\Jetpack\WooCommerceStats;
9
10use Automattic\Jetpack\Config;
11use Automattic\Jetpack\PremiumAnalytics\Sync\Sync_Status_Tracker;
12use Automattic\Jetpack\Sync\Data_Settings;
13use Automattic\Jetpack\Sync\Modules\Meta as Meta_Module;
14use Automattic\Jetpack\Sync\Modules\Posts as Posts_Module;
15use Automattic\Jetpack\Sync\Modules\Term_Relationships as Term_Relationships_Module;
16use Automattic\Jetpack\Sync\Modules\Terms as Terms_Module;
17use Automattic\Jetpack\Sync\Modules\WooCommerce_Analytics as WooCommerce_Analytics_Module;
18
19defined( 'ABSPATH' ) || exit;
20
21/**
22 * Opts in to the shared WooCommerce Analytics sync module and registers the sync configuration
23 * the WooCommerce section reads.
24 */
25class Sync_Configuration {
26
27    /**
28     * FQCN of the Analytics module shipped by the standalone WooCommerce Analytics plugin.
29     *
30     * Must track that plugin's class: if it drifts, both modules load under the same
31     * name and every analytics event syncs twice. Moot once that plugin consumes the
32     * shared module, since the class strings then match.
33     *
34     * @since 0.3.0
35     * @var string
36     */
37    const ANALYTICS_PLUGIN_MODULE_FQCN = 'Automattic\\WooCommerce\\Analytics\\Internal\\Jetpack\\Sync\\Modules\\Analytics';
38
39    /**
40     * Bookings post meta to add to Sync's post meta whitelist. Bookings are synced
41     * via the Posts + Meta modules; there is no dedicated bookings sync module.
42     *
43     * Product meta needed by analytics reports is whitelisted by the shared module.
44     *
45     * @static
46     * @var array
47     */
48    private static $postmeta_to_sync = array(
49        '_booking_parent_id',
50        '_booking_duplicate_of',
51        '_booking_product_id',
52        '_booking_resource_id',
53        '_booking_order_id',
54        '_booking_order_item_id',
55        '_booking_customer_id',
56        '_booking_start',
57        '_booking_end',
58        '_booking_all_day',
59        '_booking_persons',
60        '_booking_cost',
61        '_booking_date_cancelled',
62        '_booking_attendance_status',
63    );
64
65    /**
66     * Whether register() already ran in this request.
67     *
68     * @var bool
69     */
70    private static $registered = false;
71
72    /**
73     * Entry point called from Analytics_Dashboard::init(), once per request however many plugins bundle the package. Schedules the Sync hookups on
74     * plugins_loaded; the actual registration is a no-op unless WooCommerce is active
75     * (see {@see configure_sync()}).
76     *
77     * Call it before plugins_loaded completes: the Config built in configure_sync() wires
78     * Sync\Main::configure() from a plugins_loaded priority 2 handler that never fires later.
79     *
80     * @return void
81     */
82    public static function register(): void {
83        if ( self::$registered ) {
84            return;
85        }
86        self::$registered = true;
87        $instance         = new self();
88
89        // plugins_loaded priority 1: every plugin has loaded for the WooCommerce guard, and the
90        // Config constructed in configure_sync() still gets its priority 2 handler in this cycle.
91        if ( did_action( 'plugins_loaded' ) ) {
92            $instance->configure_sync();
93        } else {
94            add_action( 'plugins_loaded', array( $instance, 'configure_sync' ), 1 );
95        }
96    }
97
98    /**
99     * Whether WooCommerce is active in the current request.
100     *
101     * @return bool
102     */
103    private static function is_woocommerce_active(): bool {
104        return class_exists( 'WooCommerce' ) || function_exists( 'WC' );
105    }
106
107    /**
108     * Register the Jetpack Sync filters and ensure the Sync feature when WooCommerce
109     * is active.
110     *
111     * @return void
112     */
113    public function configure_sync(): void {
114        if ( ! self::is_woocommerce_active() ) {
115            return;
116        }
117
118        // The shared module is registered through the Sync config below; this only drops a duplicate.
119        add_filter( 'jetpack_sync_modules', array( $this, 'remove_duplicate_woocommerce_analytics_module' ), PHP_INT_MAX );
120        add_filter( 'jetpack_full_sync_config', array( $this, 'expand_full_sync_config' ) );
121        add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_meta_to_sync_post_meta_whitelist' ) );
122        add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_sync_reports_data', array( $this, 'skip_reports_data_before_analytics_full_sync' ) );
123        add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_delete_reports_data', array( $this, 'skip_reports_data_before_analytics_full_sync' ) );
124        add_filter( 'jetpack_premium_analytics_sync_modules', array( $this, 'register_analytics_sync_module' ) );
125
126        ( new Config() )->ensure( 'sync', $this->get_jetpack_sync_config() );
127
128        ( new Lookup_Data_Backfill() )->register();
129    }
130
131    /**
132     * Register Woo's module with the analytics sync tracker.
133     *
134     * @param string[] $modules Registered analytics module names.
135     * @return string[]
136     */
137    public function register_analytics_sync_module( array $modules ): array {
138        $modules[] = 'woocommerce_analytics';
139        return $modules;
140    }
141
142    /**
143     * Jetpack Sync module configuration.
144     *
145     * MUST_SYNC_DATA_SETTINGS is merged in because Data_Settings falls back to the full default
146     * whitelist for any filter a consumer leaves out, which would widen standalone sites.
147     *
148     * @return array Jetpack Sync config array.
149     */
150    private function get_jetpack_sync_config(): array {
151        return array_merge_recursive(
152            Data_Settings::MUST_SYNC_DATA_SETTINGS,
153            array(
154                'jetpack_sync_modules'             => array(
155                    WooCommerce_Analytics_Module::class,
156                    Meta_Module::class,
157                    Posts_Module::class,
158                    Terms_Module::class,
159                    Term_Relationships_Module::class,
160                ),
161                // Listed explicitly so the contract does not depend on which other Sync modules load.
162                'jetpack_sync_options_whitelist'   => array(
163                    'woocommerce_custom_orders_table_enabled', // Required for HPOS checksums.
164                    'woocommerce_excluded_report_order_statuses', // Required for generating analytics reports.
165                    'woocommerce_date_type', // Date used to determine the date range for analytics reports.
166                ),
167                'jetpack_sync_constants_whitelist' => array(
168                    // Syncing it makes WPCOM provision the WC Analytics tables (WOOA7S-1643). WC_ANALYTICS_VERSION
169                    // belongs to the standalone plugin and would only sync null on a PA-only store.
170                    'JETPACK_PREMIUM_ANALYTICS__VERSION',
171                ),
172            )
173        );
174    }
175
176    /**
177     * Drop the standalone plugin's Analytics module in favor of the shared one.
178     *
179     * Sync dedups by class name only, so both would load and sync every event twice. The shared
180     * module wins because the released standalone one syncs no lookup data, while the sync package
181     * advertises the lookup checksum tables for any module of this name.
182     *
183     * Runs at PHP_INT_MAX because Data_Settings re-asserts its whole module list at priority 10.
184     *
185     * @param array|mixed $modules Current Sync module class names.
186     * @return array|mixed Updated Sync module class names.
187     */
188    public function remove_duplicate_woocommerce_analytics_module( $modules ) {
189        // An emptied list is a kill switch (Jetpack's uninstaller uses one); leave it alone.
190        if ( ! is_array( $modules ) || empty( $modules ) ) {
191            return $modules;
192        }
193
194        return array_values( array_diff( $modules, array( self::ANALYTICS_PLUGIN_MODULE_FQCN ) ) );
195    }
196
197    /**
198     * Add the Analytics module to full sync, first in line.
199     *
200     * @param array $config Current full-sync configuration.
201     * @return array Updated full-sync configuration.
202     */
203    public function expand_full_sync_config( array $config ): array {
204        // Terms and term relationships must be synced before posts.
205        if ( isset( $config['posts'] ) ) {
206            unset( $config['posts'] );
207            $config += array( 'posts' => 1 );
208        }
209
210        if ( ! isset( $config['woocommerce_analytics'] ) ) {
211            $config = array( 'woocommerce_analytics' => 1 ) + $config;
212        }
213
214        return $config;
215    }
216
217    /**
218     * Drop per-order analytics reports data until an analytics full sync has started.
219     *
220     * That full sync sends the reports data of every order and overwrites whatever was synced before
221     * it. Gated on the start, not the end, so orders that change while it runs still sync.
222     *
223     * @param array|mixed $args Sync action arguments.
224     * @return array|mixed|false The arguments, or false to drop the action.
225     */
226    public function skip_reports_data_before_analytics_full_sync( $args ) {
227        // An older copy of the dashboard package cannot tell, so order changes sync as they did before the gate.
228        if ( ! method_exists( Sync_Status_Tracker::class, 'has_module_full_sync_started' ) ) {
229            return $args;
230        }
231
232        // A store the standalone plugin fully synced holds its orders on WordPress.com already.
233        if ( (int) get_option( Lookup_Data_Backfill::PLUGIN_FULL_SYNC_OPTION, 0 ) > 0 ) {
234            return $args;
235        }
236
237        return Sync_Status_Tracker::has_module_full_sync_started( 'woocommerce_analytics' ) ? $args : false;
238    }
239
240    /**
241     * Add Bookings post meta to Sync's post meta whitelist.
242     * Any changes to these meta will be synced to WordPress.com.
243     *
244     * @param array $whitelist Existing post meta whitelist.
245     * @return array Updated post meta whitelist.
246     */
247    public function add_meta_to_sync_post_meta_whitelist( array $whitelist ): array {
248        return array_merge( self::$postmeta_to_sync, $whitelist );
249    }
250}