Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.11% covered (danger)
41.11%
37 / 90
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerce_Analytics_Settings
41.11% covered (danger)
41.11%
37 / 90
55.56% covered (warning)
55.56%
5 / 9
101.69
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 is_woocommerce_active
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 configure
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
3.46
 get_data_settings
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 add_woocommerce_analytics_module
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 expand_full_sync_config
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 add_order_stats_to_checksum
5.41% covered (danger)
5.41%
2 / 37
0.00% covered (danger)
0.00%
0 / 1
5.39
 add_options_to_sync_options_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_meta_to_sync_post_meta_whitelist
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Opt-in registration for the WooCommerce Analytics sync module.
4 *
5 * @package automattic/jetpack-sync
6 */
7
8namespace Automattic\Jetpack\Sync;
9
10use Automattic\Jetpack\Sync\Modules\Meta as Meta_Module;
11use Automattic\Jetpack\Sync\Modules\Posts as Posts_Module;
12use Automattic\Jetpack\Sync\Modules\Term_Relationships as Term_Relationships_Module;
13use Automattic\Jetpack\Sync\Modules\Terms as Terms_Module;
14use Automattic\Jetpack\Sync\Modules\WooCommerce_Analytics as WooCommerce_Analytics_Module;
15use Automattic\Jetpack\Sync\Modules\WooCommerce_Analytics_Utilities;
16
17/**
18 * Registers the WooCommerce Analytics sync module and its supporting filters.
19 *
20 * The `woocommerce_analytics` module is intentionally NOT part of the default
21 * module list: it syncs high-volume order/lookup-table data that most sites do
22 * not need. Consumer packages (Premium Analytics, WooCommerce AI, ...) opt in by
23 * calling {@see register()} during bootstrap and, once WooCommerce is loaded,
24 * passing {@see get_data_settings()} into their own
25 * `Config::ensure( 'sync', ... )` call.
26 */
27class WooCommerce_Analytics_Settings {
28
29    use WooCommerce_Analytics_Utilities;
30
31    /**
32     * FQCN of the Analytics sync module shipped by the standalone
33     * `woocommerce-analytics` plugin. Both that class and the package module return
34     * `name() === 'woocommerce_analytics'`. Jetpack Sync deduplicates initialized
35     * modules by that public name, keeping the last contribution. When the
36     * standalone plugin has already contributed its module we stand down so the
37     * legacy implementation remains authoritative during migration.
38     *
39     * Kept as a string literal (rather than `::class`) so this file can be reasoned
40     * about without the standalone plugin being loaded.
41     *
42     * @var string
43     */
44    const ANALYTICS_PLUGIN_MODULE_FQCN = 'Automattic\\WooCommerce\\Analytics\\Internal\\Jetpack\\Sync\\Modules\\Analytics';
45
46    /**
47     * FQCN of the Analytics sync module shipped by WooCommerce AI.
48     *
49     * @var string
50     */
51    const WOO_AI_MODULE_FQCN = 'WooCommerce\\AI\\Sync\\Modules\\Analytics';
52
53    /**
54     * FQCN of the interim Analytics sync module shipped by Premium Analytics.
55     *
56     * @var string
57     */
58    const PREMIUM_ANALYTICS_MODULE_FQCN = 'Automattic\\Jetpack\\PremiumAnalytics\\Sync\\WooCommerce_Analytics_Module';
59
60    /**
61     * Legacy implementations that expose the same `woocommerce_analytics` module.
62     *
63     * @var string[]
64     */
65    const LEGACY_MODULE_FQCNS = array(
66        self::ANALYTICS_PLUGIN_MODULE_FQCN,
67        self::WOO_AI_MODULE_FQCN,
68        self::PREMIUM_ANALYTICS_MODULE_FQCN,
69    );
70
71    /**
72     * Options the module needs synced to WPCOM.
73     *
74     * @var array
75     */
76    const OPTIONS_TO_SYNC = array(
77        'woocommerce_custom_orders_table_enabled', // Required for HPOS checksums.
78        'woocommerce_excluded_report_order_statuses', // Required for generating analytics reports.
79        'woocommerce_date_type', // Date used to determine the date range for analytics reports.
80    );
81
82    /**
83     * Whether register() has already scheduled the hookups.
84     *
85     * @var bool
86     */
87    private static $registered = false;
88
89    /**
90     * Opt in to the WooCommerce Analytics sync module. Idempotent.
91     *
92     * Schedules the Sync hookups on plugins_loaded; the actual registration is a
93     * no-op unless WooCommerce is active (see {@see configure()}).
94     *
95     * @return void
96     */
97    public static function register(): void {
98        if ( self::$registered ) {
99            return;
100        }
101        self::$registered = true;
102
103        $instance = new self();
104
105        // Defer the WooCommerce-active guard and all hookups to plugins_loaded so they
106        // run after every plugin (including WooCommerce) has loaded, regardless of
107        // plugin load order. Priority 1 lets a consumer-constructed Jetpack Config run
108        // its own on_plugins_loaded (priority 2) handler in the same cycle.
109        if ( did_action( 'plugins_loaded' ) ) {
110            $instance->configure();
111        } else {
112            add_action( 'plugins_loaded', array( $instance, 'configure' ), 1 );
113        }
114    }
115
116    /**
117     * Whether WooCommerce is active in the current request.
118     *
119     * Public so consumers (e.g. sync milestone trackers) can decide which full sync
120     * matters: the `woocommerce_analytics` module when WooCommerce is active, or the
121     * generic initial full sync when it is not.
122     *
123     * @return bool
124     */
125    public static function is_woocommerce_active(): bool {
126        return class_exists( 'WooCommerce' ) || function_exists( 'WC' );
127    }
128
129    /**
130     * Register the Jetpack Sync filters, when WooCommerce is active.
131     *
132     * No-op unless WooCommerce is active, since the module relies on WooCommerce
133     * runtime symbols (WC_Order, the wc_order_stats table, OrderUtil, etc.).
134     *
135     * @return void
136     */
137    public function configure(): void {
138        if ( ! self::is_woocommerce_active() ) {
139            return;
140        }
141
142        // PHP_INT_MAX so we observe the fully-built module list (in particular, any
143        // contribution by the standalone `woocommerce-analytics` plugin) before
144        // deciding whether to register the package module.
145        add_filter( 'jetpack_sync_modules', array( $this, 'add_woocommerce_analytics_module' ), PHP_INT_MAX );
146        add_filter( 'jetpack_full_sync_config', array( $this, 'expand_full_sync_config' ) );
147        add_filter( 'jetpack_sync_checksum_allowed_tables', array( $this, 'add_order_stats_to_checksum' ) );
148        add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_meta_to_sync_post_meta_whitelist' ) );
149        add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_options_to_sync_options_whitelist' ) );
150    }
151
152    /**
153     * Sync data settings for the WooCommerce Analytics module.
154     *
155     * Consumers merge this into their `Config::ensure( 'sync', ... )` call. The sync
156     * package intentionally does not call `Config::ensure()` itself: connection
157     * bootstrap (and the jetpack-config dependency) belongs to the consumer.
158     * Call this after confirming WooCommerce is active.
159     *
160     * @return array Jetpack Sync data settings.
161     */
162    public static function get_data_settings(): array {
163        return array_merge_recursive(
164            Data_Settings::MUST_SYNC_DATA_SETTINGS,
165            array(
166                'jetpack_sync_modules'           => array(
167                    WooCommerce_Analytics_Module::class,
168                    Meta_Module::class,
169                    Posts_Module::class,
170                    Terms_Module::class,
171                    Term_Relationships_Module::class,
172                ),
173                'jetpack_sync_options_whitelist' => self::OPTIONS_TO_SYNC,
174            )
175        );
176    }
177
178    /**
179     * Add the WooCommerce Analytics module to the list of Jetpack Sync modules.
180     *
181     * Additive: appends to whatever module list is already configured rather than
182     * replacing it. When a legacy consumer has already contributed its own module,
183     * removes the shared class that Config data settings may have added earlier.
184     *
185     * @param array|mixed $modules The current list of sync module class names.
186     * @return array|mixed
187     */
188    public function add_woocommerce_analytics_module( $modules ) {
189        if ( ! is_array( $modules ) ) {
190            return $modules;
191        }
192
193        if ( array_intersect( self::LEGACY_MODULE_FQCNS, $modules ) ) {
194            return array_values( array_diff( $modules, array( WooCommerce_Analytics_Module::class ) ) );
195        }
196
197        if ( ! in_array( WooCommerce_Analytics_Module::class, $modules, true ) ) {
198            $modules[] = WooCommerce_Analytics_Module::class;
199        }
200
201        return $modules;
202    }
203
204    /**
205     * Expand full sync config with modules required by WooCommerce Analytics if not already present.
206     *
207     * @param array $config The current full sync configuration.
208     * @return array The modified full sync configuration.
209     */
210    public function expand_full_sync_config( array $config ): array {
211        if ( ! $this->can_site_sync_orders() ) {
212            return $config;
213        }
214
215        // Let's ensure Terms and Term_Relationships will always get synced before Posts during Full Sync.
216        if ( isset( $config['posts'] ) ) {
217            unset( $config['posts'] );
218            $config += array( 'posts' => 1 );
219        }
220
221        if ( ! isset( $config['woocommerce_analytics'] ) ) {
222            $config = array( 'woocommerce_analytics' => 1 ) + $config;
223        }
224
225        return $config;
226    }
227
228    /**
229     * Adds the order stats and lookup tables to the checksum allowed tables.
230     *
231     * @param array $tables The current checksum allowed tables.
232     * @return array The modified checksum allowed tables.
233     */
234    public function add_order_stats_to_checksum( array $tables ): array {
235        if ( ! $this->can_site_sync_orders() ) {
236            return $tables;
237        }
238
239        global $wpdb;
240        $is_table_enabled = function () {
241            return false !== Modules::get_module( 'woocommerce_analytics' );
242        };
243
244        $order_stats_checksum_tables = array(
245            'wc_order_stats'          => array(
246                'table'                     => "{$wpdb->prefix}wc_order_stats",
247                'range_field'               => 'order_id',
248                'key_fields'                => array( 'order_id' ),
249                'checksum_fields'           => array( 'date_paid', 'date_completed', 'total_sales' ),
250                'checksum_text_fields'      => array( 'status' ),
251                'is_table_enabled_callback' => $is_table_enabled,
252            ),
253            'wc_order_product_lookup' => array(
254                'table'                     => "{$wpdb->prefix}wc_order_product_lookup",
255                'range_field'               => 'order_id',
256                'key_fields'                => array( 'order_id', 'order_item_id' ),
257                'checksum_fields'           => array( 'product_id', 'variation_id', 'product_qty', 'product_net_revenue', 'date_created' ),
258                'is_table_enabled_callback' => $is_table_enabled,
259            ),
260            'wc_order_coupon_lookup'  => array(
261                'table'                     => "{$wpdb->prefix}wc_order_coupon_lookup",
262                'range_field'               => 'order_id',
263                'key_fields'                => array( 'order_id', 'coupon_id' ),
264                'checksum_fields'           => array( 'discount_amount', 'date_created' ),
265                'is_table_enabled_callback' => $is_table_enabled,
266            ),
267            'wc_order_tax_lookup'     => array(
268                'table'                     => "{$wpdb->prefix}wc_order_tax_lookup",
269                'range_field'               => 'order_id',
270                'key_fields'                => array( 'order_id', 'tax_rate_id' ),
271                'checksum_fields'           => array( 'order_tax', 'total_tax', 'shipping_tax', 'date_created' ),
272                'is_table_enabled_callback' => $is_table_enabled,
273            ),
274        );
275
276        return array_merge( $tables, $order_stats_checksum_tables );
277    }
278
279    /**
280     * Add the options needed by WooCommerce Analytics to Sync's options whitelist.
281     *
282     * Also covered by {@see get_data_settings()} for consumers using Config::ensure();
283     * the filter keeps filter-only consumers (whose host handles the ensure) working.
284     *
285     * @param array $whitelist Existing options whitelist.
286     * @return array Updated options whitelist.
287     */
288    public function add_options_to_sync_options_whitelist( array $whitelist ): array {
289        return array_values( array_unique( array_merge( $whitelist, self::OPTIONS_TO_SYNC ) ) );
290    }
291
292    /**
293     * Add the product meta needed by WooCommerce Analytics reports to Sync's post meta whitelist.
294     *
295     * @param array $whitelist Existing post meta whitelist.
296     * @return array Updated post meta whitelist.
297     */
298    public function add_meta_to_sync_post_meta_whitelist( array $whitelist ): array {
299        return array_merge(
300            array(
301                '_stock',
302                '_stock_quantity',
303                '_cogs_total_value',
304                '_global_unique_id',
305            ),
306            $whitelist
307        );
308    }
309}