Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
21.64% covered (danger)
21.64%
103 / 476
34.69% covered (danger)
34.69%
17 / 49
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerce_Analytics
21.73% covered (danger)
21.73%
103 / 474
34.69% covered (danger)
34.69%
17 / 49
10226.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_woocommerce_analytics_options_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_woocommerce_analytics_post_meta_whitelist
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 id_field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 table
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init_listeners
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 expand_data
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 init_full_sync_listeners
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_full_sync_actions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_supported_object_types
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_objects_by_id
11.43% covered (danger)
11.43%
4 / 35
0.00% covered (danger)
0.00%
0 / 1
95.07
 get_object_by_id
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
10.75
 enqueue_full_sync_actions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 estimate_full_sync_actions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 get_where_sql
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 init_before_send
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 build_full_sync_action_array
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 get_next_chunk
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 filter_analytics_objects_by_size
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 sync_analytics_reports_data
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 sync_deleted_analytics_data
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 build_woocommerce_analytics_reports_data
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 build_woocommerce_analytics_reports_lookup_data
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 get_order_attribution_data
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
7.01
 get_order_attribution_meta_prefix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_order_stats_data
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
182
 uses_new_full_refund_data
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_num_items_sold
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_net_total
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_total_fees_tax
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_order_stats_item
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_order_stats_items
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 is_cogs_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_order_product_data
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
110
 get_order_product_cogs_value
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
43.05
 get_order_product_data_from_db
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 is_refund_order
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 get_order_coupon_data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 get_order_coupon_data_from_db
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 get_order_tax_data
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 get_order_tax_data_from_db
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 get_order_lookup_data_from_db
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 get_order_stats_data_from_db
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 do_order_status_discrepancy_check
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 normalize_order_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 datetime_to_object
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 format_utc_offset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_site_datetimezone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * WooCommerce Analytics sync module.
4 *
5 * Syncs the data behind WooCommerce Analytics reports (wc_order_stats and the
6 * order product/coupon/tax lookup tables) to WordPress.com.
7 *
8 * Sync module name: `woocommerce_analytics`. The name, the action names, and the
9 * payload shapes are consumed by the WPCOM receiving side and by consumer packages
10 * (Premium Analytics, WooCommerce AI); treat them as a public contract.
11 *
12 * This module is NOT registered by default. Consumers own its registration,
13 * WooCommerce runtime guard, full-sync policy, and any additional Sync data
14 * configuration. The module provides the minimum option and post meta requirements.
15 *
16 * WooCommerce is a runtime (not composer) dependency. The WC classes
17 * referenced here resolve via WooCommerce's autoloader at runtime; registration is
18 * guarded so this class is only instantiated when WooCommerce is active.
19 *
20 * @package automattic/jetpack-sync
21 */
22
23namespace Automattic\Jetpack\Sync\Modules;
24
25use Automattic\WooCommerce\Admin\API\Reports\Coupons\DataStore as CouponsDataStore;
26use Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore as OrderStatsDataStore;
27use Automattic\WooCommerce\Internal\Fulfillments\FulfillmentUtils;
28use Automattic\WooCommerce\Utilities\FeaturesUtil;
29use Automattic\WooCommerce\Utilities\OrderUtil;
30use DateTimeZone;
31use WC_Abstract_Order;
32use WC_Coupon;
33use WC_DateTime;
34use WC_Order;
35use WC_Order_Factory;
36use WC_Tax;
37
38if ( ! defined( 'ABSPATH' ) ) {
39    exit( 0 );
40}
41
42/**
43 * WooCommerce Analytics Module class.
44 */
45class WooCommerce_Analytics extends Module {
46
47    /**
48     * Options required by WooCommerce Analytics Sync.
49     *
50     * @var string[]
51     */
52    private static $options_whitelist = array(
53        'woocommerce_excluded_report_order_statuses',
54    );
55
56    /**
57     * Post meta required by WooCommerce Analytics Sync.
58     *
59     * @var string[]
60     */
61    private static $post_meta_whitelist = array(
62        '_stock',
63        '_stock_quantity',
64        '_cogs_total_value',
65        '_global_unique_id',
66    );
67
68    /**
69     * Constructor.
70     */
71    public function __construct() {
72        add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_woocommerce_analytics_options_whitelist' ), 10 );
73        add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_woocommerce_analytics_post_meta_whitelist' ), 10 );
74    }
75
76    /**
77     * Add the options required by WooCommerce Analytics Sync.
78     *
79     * @param array $list Existing options whitelist.
80     * @return array Updated options whitelist.
81     */
82    public function add_woocommerce_analytics_options_whitelist( $list ) {
83        return array_values( array_unique( array_merge( $list, self::$options_whitelist ) ) );
84    }
85
86    /**
87     * Add the post meta required by WooCommerce Analytics Sync.
88     *
89     * @param array $list Existing post meta whitelist.
90     * @return array Updated post meta whitelist.
91     */
92    public function add_woocommerce_analytics_post_meta_whitelist( $list ) {
93        return array_values( array_unique( array_merge( $list, self::$post_meta_whitelist ) ) );
94    }
95
96    /**
97     * Get the module name.
98     *
99     * @return string
100     */
101    public function name() {
102        return 'woocommerce_analytics';
103    }
104
105    /**
106     * Get the ID field for the module.
107     *
108     * @return string
109     */
110    public function id_field() {
111        return 'order_id';
112    }
113
114    /**
115     * Get the table in the database.
116     *
117     * @return string
118     */
119    public function table() {
120        global $wpdb;
121        return $wpdb->prefix . 'wc_order_stats';
122    }
123
124    /**
125     * Init listeners.
126     *
127     * @param callable $handler Action handler callable.
128     *
129     * @return void
130     */
131    public function init_listeners( $handler ) {
132        // Actions to update order stats.
133        add_action( 'woocommerce_analytics_delete_order_stats', array( $this, 'sync_deleted_analytics_data' ) );
134
135        // In WooCommerce 10.3+ the new action is available.
136        if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '10.3', '>=' ) ) {
137            add_action( 'woocommerce_order_scheduler_after_import_order', array( $this, 'sync_analytics_reports_data' ) );
138        } else {
139            add_action( 'woocommerce_analytics_update_order_stats', array( $this, 'sync_analytics_reports_data' ) );
140        }
141
142        // Sync actions.
143        add_action( 'woocommerce_analytics_sync_reports_data', $handler );
144        add_action( 'woocommerce_analytics_delete_reports_data', $handler );
145
146        // Expand data.
147        add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_sync_reports_data', array( $this, 'expand_data' ) );
148        add_filter( 'jetpack_sync_before_enqueue_woocommerce_analytics_delete_reports_data', array( $this, 'expand_data' ) );
149    }
150
151    /**
152     * Expand order stats data and attribution data.
153     *
154     * @param array|mixed $args List of arguments.
155     *
156     * @return array|false
157     */
158    public function expand_data( $args ) {
159        if ( ! is_array( $args ) || ! isset( $args[0] ) ) {
160            return false;
161        }
162
163        $data = $args[0];
164
165        return $data;
166    }
167
168    /**
169     * Init full sync listeners.
170     *
171     * @param callable $handler Action handler callable.
172     *
173     * @return void
174     */
175    public function init_full_sync_listeners( $handler ) {
176        add_action( 'jetpack_full_sync_woocommerce_analytics', $handler );
177    }
178
179    /**
180     * Get full sync actions.
181     *
182     * @return string[] The full sync actions.
183     */
184    public function get_full_sync_actions() {
185        return array( 'jetpack_full_sync_woocommerce_analytics' );
186    }
187
188    /**
189     * Get the supported object types.
190     *
191     * @return array The supported object types.
192     */
193    private function get_supported_object_types() {
194        return array( 'order', 'order_tax_lookup', 'order_product_lookup', 'order_coupon_lookup' );
195    }
196
197    /**
198     * Retrieves multiple orders data by their ID.
199     *
200     * @param string $object_type Type of object to retrieve. Should be `order`.
201     * @param array  $ids         List of order IDs.
202     *
203     * @return array
204     */
205    public function get_objects_by_id( $object_type, $ids ) {
206        if ( empty( $ids ) || ! is_array( $ids ) || empty( $object_type ) ) {
207            return array();
208        }
209
210        if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
211            return array();
212        }
213
214        $orders = wc_get_orders(
215            array(
216                'post__in'    => $ids,
217                'post_status' => WooCommerce_HPOS_Orders::get_all_possible_order_status_keys(),
218                'limit'       => -1,
219                'orderby'     => 'id',
220                'order'       => 'DESC',
221            )
222        );
223
224        // Get the order stats data for the orders.
225        $order_stats_items = $this->get_order_stats_items( $ids );
226        $order_stats_data  = array();
227        if ( ! empty( $order_stats_items ) ) {
228            $order_stats_data = array_column( $order_stats_items, null, 'order_id' );
229        }
230
231        $orders_data     = array();
232        $found_order_ids = array();
233        foreach ( $orders as $order ) {
234            $order_id          = $order->get_id();
235            $found_order_ids[] = $order_id;
236            if ( 'order' === $object_type ) {
237                // Sync everything if the object type is order.
238                $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_data( $order );
239            } else {
240                $orders_data[ $order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
241            }
242            if ( isset( $order_stats_data[ $order_id ] ) ) {
243                $this->do_order_status_discrepancy_check( $order, $order_stats_data[ $order_id ] );
244            }
245        }
246
247        // Check for missing order_ids in wc_order_stats table for orders that were not found.
248        $missing_order_ids = array_diff( $ids, $found_order_ids );
249
250        /**
251         * Trigger missing orders detected action.
252         *
253         * @param array $missing_order_ids The missing order IDs.
254         */
255        do_action( 'woocommerce_analytics_missing_orders_detected', $missing_order_ids );
256
257        foreach ( $missing_order_ids as $missing_order_id ) {
258            if ( 'order' === $object_type ) {
259                $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_data( $missing_order_id );
260            } else {
261                $orders_data[ $missing_order_id ] = $this->build_woocommerce_analytics_reports_lookup_data( $missing_order_id, $object_type );
262            }
263        }
264        // Let's sort the orders by ID in descending order. This is useful for the full sync to ensure that the latest orders are processed first.
265        krsort( $orders_data, SORT_NUMERIC );
266        return $orders_data;
267    }
268
269    /**
270     * Retrieve the analytics order data by its ID.
271     *
272     * @param string $object_type Type of the sync object.
273     * @param int    $id          ID of the sync object.
274     * @return mixed Object, or false if the object is invalid.
275     */
276    public function get_object_by_id( $object_type, $id ) {
277        if ( ! in_array( $object_type, $this->get_supported_object_types(), true ) ) {
278            return false;
279        }
280
281        $order = wc_get_order( $id );
282
283        if ( ! $order instanceof WC_Abstract_Order ) {
284            $order = $id; // If the order does not exists. We'll check if the order_id exists in wc_order_stats table.
285        }
286
287        if ( 'order' === $object_type ) {
288            return $this->build_woocommerce_analytics_reports_data( $order );
289        }
290
291        return $this->build_woocommerce_analytics_reports_lookup_data( $order, $object_type );
292    }
293
294    /**
295     * Enqueue full sync actions.
296     *
297     * @param array   $config               Full sync configuration.
298     * @param int     $max_items_to_enqueue Maximum number of items to enqueue.
299     * @param boolean $state                True if full sync has finished enqueueing this module.
300     * @return array Number of actions enqueued, and next module state.
301     */
302    public function enqueue_full_sync_actions( $config, $max_items_to_enqueue, $state ) {
303        return $this->enqueue_all_ids_as_action(
304            'jetpack_full_sync_woocommerce_analytics',
305            $this->table(),
306            $this->id_field(),
307            $this->get_where_sql( $config ),
308            $max_items_to_enqueue,
309            $state
310        );
311    }
312
313    /**
314     * Estimate full sync actions.
315     *
316     * @param array $config Full sync configuration.
317     * @return int Number of items yet to be enqueued.
318     */
319    public function estimate_full_sync_actions( $config ) {
320        global $wpdb;
321
322        $query = "SELECT COUNT(*) FROM {$this->table()}";
323
324        $where_sql = $this->get_where_sql( $config );
325        if ( $where_sql ) {
326            $query .= ' WHERE ' . $where_sql;
327        }
328
329        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
330        $count = (int) $wpdb->get_var( $query );
331
332        return (int) ceil( $count / self::ARRAY_CHUNK_SIZE );
333    }
334
335    /**
336     * Get where SQL clause for the module.
337     *
338     * @param array $config Full sync configuration.
339     * @return string
340     */
341    public function get_where_sql( $config ) {
342        global $wpdb;
343
344        $where = '1=1';
345
346        if ( ! empty( $config['start_date'] ) ) {
347            $where .= $wpdb->prepare( ' AND date_created >= %s', $config['start_date'] );
348        }
349        if ( ! empty( $config['end_date'] ) ) {
350            $where .= $wpdb->prepare( ' AND date_created <= %s', $config['end_date'] );
351        }
352
353        /**
354         * Filter the WHERE SQL for analytics full sync
355         *
356         * @param string $where The WHERE SQL clause
357         * @param array  $config The sync configuration
358         */
359        return apply_filters( 'woocommerce_analytics_full_sync_where_sql', $where, $config );
360    }
361
362    /**
363     * Initialize module in the sender.
364     */
365    public function init_before_send() {
366        // Full sync.
367        add_filter(
368            'jetpack_sync_before_send_jetpack_full_sync_woocommerce_analytics',
369            array( $this, 'build_full_sync_action_array' )
370        );
371    }
372
373    /**
374     * Build the full sync action object.
375     *
376     * @param array $args An array with filtered objects and previous end.
377     *
378     * @return array An array with orders and previous end.
379     */
380    public function build_full_sync_action_array( $args ) {
381        list( $filtered_orders, $previous_end ) = $args;
382        return array(
383            'orders'       => $filtered_orders['objects'],
384            'previous_end' => $previous_end,
385        );
386    }
387
388    /**
389     * Given the Module Configuration and Status return the next chunk of items to send.
390     * This function also expands the posts and metadata and filters them based on the maximum size constraints.
391     *
392     * @param array $config This module Full Sync configuration.
393     * @param array $status This module Full Sync status.
394     * @param int   $chunk_size Chunk size.
395     *
396     * @return array
397     */
398    public function get_next_chunk( $config, $status, $chunk_size ) {
399
400        $order_ids = parent::get_next_chunk( $config, $status, $chunk_size );
401
402        if ( empty( $order_ids ) ) {
403            return array();
404        }
405
406        $orders = $this->get_objects_by_id( 'order', $order_ids );
407
408        // If no orders were fetched, make sure to return the expected structure so that status is updated correctly.
409        if ( empty( $orders ) ) {
410            return array(
411                'object_ids' => $order_ids,
412                'objects'    => array(),
413            );
414        }
415
416        // Filter the orders based on the maximum size constraints.
417        list( $filtered_order_ids, $filtered_orders, ) = $this->filter_analytics_objects_by_size( $orders );
418
419        return array(
420            'object_ids' => $filtered_order_ids,
421            'objects'    => $filtered_orders,
422        );
423    }
424
425    /**
426     * Filters objects and metadata based on maximum size constraints.
427     * It always allows the first object with its metadata, even if they exceed the limit.
428     *
429     * @param array $objects The array of objects to filter.
430     *
431     * @return array An array containing the filtered object IDsand  filtered objects
432     */
433    public function filter_analytics_objects_by_size( $objects ) {
434        $filtered_objects    = array();
435        $filtered_object_ids = array();
436        $current_size        = 0;
437
438        foreach ( $objects as $key => $value ) {
439            $object_size = strlen( maybe_serialize( $value ) );
440
441            // Always allow the first object.
442            if ( empty( $filtered_object_ids ) || ( $current_size + $object_size ) <= self::MAX_SIZE_FULL_SYNC ) {
443                $filtered_object_ids[]    = $key;
444                $filtered_objects[ $key ] = $value;
445                $current_size            += $object_size;
446            } else {
447                break;
448            }
449        }
450
451        return array(
452            $filtered_object_ids,
453            $filtered_objects,
454        );
455    }
456
457    /**
458     * Handle Sync analytics reports data.
459     *
460     * @param int $order_id The order ID.
461     * @return void
462     */
463    public function sync_analytics_reports_data( $order_id ) {
464
465        $data = $this->get_object_by_id( 'order', $order_id );
466
467        if ( ! $data ) {
468            return;
469        }
470
471        /**
472         * Trigger the action to sync the reports data.
473         *
474         * @param array $data Analytics reports sync data.
475         */
476        do_action( 'woocommerce_analytics_sync_reports_data', $data );
477    }
478
479    /**
480     * Handle syncing of analytics deletion data.
481     *
482     * @param int $order_id The order ID.
483     * @return void
484     */
485    public function sync_deleted_analytics_data( $order_id ) {
486        if ( empty( $order_id ) ) {
487            return;
488        }
489
490        $data = array(
491            'id' => $order_id,
492        );
493
494        /**
495         * Filter the deletion data before syncing.
496         *
497         * @param array $data The deletion data.
498         */
499        $data = apply_filters( 'woocommerce_analytics_deletion_data', $data );
500
501        /**
502         * Trigger the action to sync the deletion.
503         *
504         * @param array $data The deletion sync data.
505         */
506        do_action( 'woocommerce_analytics_delete_reports_data', $data );
507    }
508
509    /**
510     * Build the WooCommerce analytics reports data.
511     *
512     * @param mixed $order The order ID or the WC_Order object.
513     * @return array The reports data.
514     */
515    protected function build_woocommerce_analytics_reports_data( $order ) {
516        $data_types = array(
517            'order_stats'            => $this->get_order_stats_data( $order ),
518            'order_attribution_data' => $this->get_order_attribution_data( $order ),
519            'order_product_data'     => $this->get_order_product_data( $order ),
520            'order_coupon_data'      => $this->get_order_coupon_data( $order ),
521            'order_tax_data'         => $this->get_order_tax_data( $order ),
522        );
523
524        $reports_data = array_filter( $data_types );
525
526        /**
527         * Filter the reports data before syncing.
528         *
529         * @param array                        $data  The reports data.
530         * @param WC_Abstract_Order|int|string $order The order object or ID.
531         */
532        return apply_filters( 'woocommerce_analytics_reports_data', $reports_data, $order );
533    }
534
535    /**
536     * Build the WooCommerce analytics reports data for lookup tables.
537     *
538     * @param mixed  $order The order ID or the WC_Order object.
539     * @param string $object_type The object type.
540     * @return array The reports data.
541     */
542    protected function build_woocommerce_analytics_reports_lookup_data( $order, $object_type ) {
543        $report_data = array();
544        switch ( $object_type ) {
545            case 'order_product_lookup':
546                $report_data['order_product_data'] = $this->get_order_product_data( $order );
547                break;
548            case 'order_coupon_lookup':
549                $report_data['order_coupon_data'] = $this->get_order_coupon_data( $order );
550                break;
551            case 'order_tax_lookup':
552                $report_data['order_tax_data'] = $this->get_order_tax_data( $order );
553                break;
554        }
555
556        /**
557         * Filter the reports lookup data before syncing.
558         *
559         * @param array                        $data        The reports lookup data.
560         * @param WC_Abstract_Order|int|string $order       The order object or ID.
561         * @param string                       $object_type The object type.
562         */
563        return apply_filters( 'woocommerce_analytics_reports_lookup_data', $report_data, $order, $object_type );
564    }
565
566    /**
567     * Get order attribution data.
568     *
569     * @param mixed $order The order ID or the WC_Order object.
570     * @return array|bool The order attribution data or false if the order is invalid.
571     */
572    protected function get_order_attribution_data( $order ) {
573        if ( is_numeric( $order ) ) {
574            $order = wc_get_order( $order );
575        }
576
577        if ( ! $order ) {
578            return false;
579        }
580
581        $order_id           = $order->get_id();
582        $type               = $order->get_type();
583        $attribution_prefix = $this->get_order_attribution_meta_prefix();
584        $allowed_keys       = array(
585            'utm_campaign',
586            'utm_source',
587            'utm_medium',
588            'utm_content',
589            'utm_term',
590            'utm_source_platform',
591            'origin',
592            'device_type',
593            'source_type',
594        );
595
596        // Refunds inherit attribution from their parent order. Fall back to the refund
597        // itself when the parent can no longer be loaded.
598        $order_object_to_use = $order;
599        if ( 'shop_order_refund' === $type && ! empty( $order->get_parent_id() ) ) {
600            $parent_order = wc_get_order( $order->get_parent_id() );
601            if ( $parent_order ) {
602                $order_object_to_use = $parent_order;
603            }
604        }
605
606        $attribution_data = array(
607            'order_id' => $order_id,
608        );
609
610        foreach ( $allowed_keys as $key ) {
611            $meta_key                 = $attribution_prefix . $key;
612            $attribution_data[ $key ] = $order_object_to_use->get_meta( $meta_key, true );
613        }
614
615        return $attribution_data;
616    }
617
618    /**
619     * Get the filtered WooCommerce order attribution meta prefix.
620     *
621     * @return string The normalized meta prefix.
622     */
623    private function get_order_attribution_meta_prefix() {
624        /**
625         * Filters the prefix used for order attribution meta keys.
626         *
627         * @since 5.1.0
628         *
629         * @param string $prefix The order attribution meta key prefix.
630         */
631        $prefix = (string) apply_filters(
632            'wc_order_attribution_tracking_field_prefix',
633            'wc_order_attribution_'
634        );
635
636        return '_' . trim( $prefix, '_' ) . '_';
637    }
638
639    /**
640     * Handler order stats update.
641     *
642     * @param mixed $order The order ID or the WC_Order object.
643     * @return array|bool The order attribution data or false if the order stats item does not exist.
644     */
645    protected function get_order_stats_data( $order ) {
646        if ( is_numeric( $order ) ) {
647            $order_id = $order;
648            $order    = wc_get_order( $order );
649        } elseif ( $order instanceof WC_Abstract_Order ) {
650            $order_id = $order->get_id();
651        } else {
652            return false;
653        }
654
655        // If the order does not exit, check if the stats item is present in the wc_order_stats table.
656        if ( ! $order ) {
657            $order_stats_data_from_db = $this->get_order_stats_data_from_db( $order_id );
658            return $order_stats_data_from_db;
659        }
660
661        $order_fulfillment_status = null;
662        // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
663        if ( is_callable( array( OrderStatsDataStore::class, 'has_fulfillment_status_column' ) ) && OrderStatsDataStore::has_fulfillment_status_column() ) {
664            $order_stats_item         = $this->get_order_stats_item( $order->get_id() );
665            $order_fulfillment_status = $order_stats_item['fulfillment_status'] ?? null;
666        } elseif ( is_callable( array( FulfillmentUtils::class, 'get_order_fulfillment_status' ) ) && $order instanceof WC_Order ) {
667            $fulfillment_status       = FulfillmentUtils::get_order_fulfillment_status( $order );
668            $order_fulfillment_status = 'no_fulfillments' !== $fulfillment_status ? $fulfillment_status : null;
669        }
670
671        $order_stats_data = array(
672            'order_id'           => $order->get_id(),
673            'parent_id'          => $order->get_parent_id(),
674            'date_created'       => self::datetime_to_object( $order->get_date_created() ),
675            'date_paid'          => self::datetime_to_object( $order->get_date_paid() ),
676            'date_completed'     => self::datetime_to_object( $order->get_date_completed() ),
677            'num_items_sold'     => self::get_num_items_sold( $order ),
678            'total_sales'        => $order->get_total(),
679            'tax_total'          => $order->get_total_tax(),
680            'total_fees'         => $order->get_total_fees(),
681            'total_fees_tax'     => self::get_total_fees_tax( $order ),
682            'shipping_total'     => $order->get_shipping_total(),
683            'shipping_tax'       => $order->get_shipping_tax(),
684            'discount_total'     => $order->get_discount_total(),
685            'discount_tax'       => $order->get_discount_tax(),
686            'net_total'          => self::get_net_total( $order ),
687            'returning_customer' => $order->is_returning_customer(),
688            'status'             => self::normalize_order_status( $order->get_status() ),
689            'customer_id'        => $order->get_report_customer_id(),
690            'fulfillment_status' => $order_fulfillment_status,
691        );
692
693        if ( 'shop_order_refund' === $order->get_type() ) {
694            $parent_order = wc_get_order( $order->get_parent_id() );
695            if ( $parent_order ) {
696                $order_stats_data['parent_id'] = $parent_order->get_id();
697
698                $refund_type = $order->get_meta( '_refund_type' );
699                if ( 'full' === $refund_type && self::uses_new_full_refund_data() ) {
700                    $order_stats_data['tax_total']      = -1 * $parent_order->get_total_tax();
701                    $order_stats_data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order );
702                    $order_stats_data['net_total']      = -1 * self::get_net_total( $parent_order );
703                    $order_stats_data['shipping_total'] = -1 * (float) $parent_order->get_shipping_total();
704                }
705            }
706            /**
707             * Set date_completed and date_paid the same as date_created to avoid problems
708             * when they are being used to sort the data, as refunds don't have them filled
709             */
710            $date_created_gmt                   = self::datetime_to_object( $order->get_date_created() );
711            $order_stats_data['date_completed'] = $date_created_gmt;
712            $order_stats_data['date_paid']      = $date_created_gmt;
713        }
714
715        return $order_stats_data;
716    }
717
718    /**
719     * Check whether WooCommerce stores full refunds using the new data format.
720     *
721     * @return bool Whether the new full-refund data format is in use.
722     */
723    private static function uses_new_full_refund_data() {
724        if ( ! is_callable( array( OrderUtil::class, 'uses_new_full_refund_data' ) ) ) {
725            return false;
726        }
727
728        // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
729        return OrderUtil::uses_new_full_refund_data();
730    }
731
732    /**
733     * Calculation methods.
734     */
735
736    /**
737     * Get number of items sold among all orders.
738     *
739     * @param WC_Order $order WC_Order object.
740     * @return int
741     */
742    protected static function get_num_items_sold( $order ) {
743        $num_items = 0;
744
745        $line_items = $order->get_items( 'line_item' );
746        foreach ( $line_items as $line_item ) {
747            $num_items += $line_item->get_quantity();
748        }
749
750        return $num_items;
751    }
752
753    /**
754     * Get the net amount from an order without shipping, tax, or refunds.
755     *
756     * @param WC_Order $order WC_Order object.
757     * @return float
758     */
759    protected static function get_net_total( $order ) {
760        $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
761        return $net_total;
762    }
763
764    /**
765     * Get the total fees tax from an order.
766     *
767     * @param WC_Order $order WC_Order object.
768     * @return float
769     */
770    protected static function get_total_fees_tax( $order ) {
771        $total_fees_tax = array_sum(
772            array_map(
773                function ( $item ) {
774                    return $item->get_total_tax();
775                },
776                array_values( $order->get_items( 'fee' ) )
777            )
778        );
779
780        return $total_fees_tax;
781    }
782
783    /**
784     * Get the order stats row for a given order ID.
785     *
786     * @param int $order_id The order ID.
787     * @return array|null|void Database query result in format specified by $output or null on failure.
788     */
789    private function get_order_stats_item( $order_id ) {
790        global $wpdb;
791
792        $query = $wpdb->prepare(
793            "SELECT * FROM {$this->table()} WHERE order_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
794            $order_id
795        );
796
797        return $wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
798    }
799
800    /**
801     * Get the order stats rows for a given order IDs.
802     *
803     * @param array $order_ids The order IDs.
804     * @return array|null Database query result in format specified by $output or null on failure.
805     */
806    private function get_order_stats_items( $order_ids ) {
807        global $wpdb;
808
809        $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
810        $query        = $wpdb->prepare(
811            "SELECT * FROM {$this->table()} WHERE order_id IN ( $placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
812            $order_ids
813        );
814
815        return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
816    }
817
818    /**
819     * Check if the COGS feature is enabled.
820     *
821     * @return bool True if the COGS feature is enabled, false otherwise.
822     */
823    private function is_cogs_enabled() {
824        return FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
825    }
826
827    /**
828     * Get order product lookup data.
829     *
830     * @param mixed $order The order ID or the WC_Order object.
831     * @return array|bool The order product data or false if no data exists.
832     */
833    protected function get_order_product_data( $order ) {
834        if ( is_numeric( $order ) ) {
835            $order_id = $order;
836            $order    = wc_get_order( $order );
837        } elseif ( $order instanceof WC_Abstract_Order ) {
838            $order_id = $order->get_id();
839        } else {
840            return false;
841        }
842
843        // If the order does not exist, check if product lookup data exists in the database.
844        if ( ! $order ) {
845            return $this->get_order_product_data_from_db( $order_id );
846        }
847
848        // Get the order product data from the order object.
849        $order_products = $order->get_items( 'line_item' );
850
851        if ( empty( $order_products ) ) {
852            // Not a common use case, but there could be a case where this returns empty.
853            return $this->get_order_product_data_from_db( $order_id );
854        }
855
856        $is_refund_order = $this->is_refund_order( $order_id );
857        $round_tax       = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
858        $decimals        = wc_get_price_decimals();
859
860        $results = array();
861        foreach ( $order_products as $order_product ) {
862            $shipping_amount     = $order->get_item_shipping_amount( $order_product );
863            $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_product );
864            $coupon_amount       = $order->get_item_coupon_amount( $order_product );
865            // Tax amount.
866            $tax_amount  = 0;
867            $order_taxes = $order->get_taxes();
868            $tax_data    = $order_product->get_taxes();
869            foreach ( $order_taxes as $tax_item ) {
870                $tax_item_id = $tax_item->get_rate_id();
871                $tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
872            }
873
874            $net_revenue = round( $order_product->get_total( 'edit' ), $decimals );
875            if ( $round_tax ) {
876                $tax_amount = round( $tax_amount, $decimals );
877            }
878
879            $product_id  = $order_product->get_product_id();
880            $cogs_amount = $this->get_order_product_cogs_value( $order_product );
881
882            $product_data = array(
883                'order_id'              => $order_id,
884                'order_item_id'         => $order_product->get_id(),
885                'product_id'            => $product_id,
886                'variation_id'          => $order_product->get_variation_id(),
887                'product_qty'           => $order_product->get_quantity(),
888                'product_net_revenue'   => $net_revenue,
889                'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
890                'shipping_amount'       => $shipping_amount,
891                'shipping_tax_amount'   => $shipping_tax_amount,
892                'coupon_amount'         => $coupon_amount,
893                'tax_amount'            => $tax_amount,
894                'customer_id'           => $order->get_report_customer_id(),
895                'date_created'          => self::datetime_to_object( $order->get_date_created() ),
896                'cogs_amount'           => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
897            );
898
899            $results[] = $product_data;
900        }
901
902        return $results;
903    }
904
905    /**
906     * Get COGS value for an order product.
907     *
908     * @param object|false $order_product The order product object, or false if it no longer exists.
909     * @return float|null The COGS amount or null if not available.
910     */
911    private function get_order_product_cogs_value( $order_product ) {
912        if ( ! is_object( $order_product ) || ! method_exists( $order_product, 'get_cogs_value' ) || ! $this->is_cogs_enabled() ) {
913            return null;
914        }
915
916        $cogs_amount = $order_product->get_cogs_value();
917
918        // Only fallback to product's COGS value if order product's COGS is null (not set).
919        if ( null === $cogs_amount ) {
920            $product_id = $order_product->get_product_id();
921            $product    = wc_get_product( $product_id );
922
923            if ( $product && method_exists( $product, 'get_cogs_value' ) ) {
924                $product_cogs_value = $product->get_cogs_value();
925                if ( null !== $product_cogs_value ) {
926                    $cogs_amount = $product_cogs_value;
927                }
928            }
929        }
930
931        return $cogs_amount;
932    }
933
934    /**
935     * Get order product lookup data from database.
936     *
937     * @param int $order_id The order ID.
938     * @return array|bool The order product data or false if no data exists.
939     */
940    protected function get_order_product_data_from_db( $order_id ) {
941        $results = $this->get_order_lookup_data_from_db( 'wc_order_product_lookup', $order_id );
942
943        if ( empty( $results ) ) {
944            return false;
945        }
946
947        $is_refund_order = $this->is_refund_order( $order_id );
948
949        $parsed_results = array();
950        foreach ( $results as $result ) {
951            $order_item  = WC_Order_Factory::get_order_item( absint( $result['order_item_id'] ) );
952            $cogs_amount = $this->get_order_product_cogs_value( $order_item );
953
954            $product_data = array(
955                'date_created'          => self::datetime_to_object( $result['date_created'] ),
956                'product_net_revenue'   => floatval( $result['product_net_revenue'] ),
957                'product_gross_revenue' => floatval( $result['product_gross_revenue'] ),
958                'shipping_amount'       => floatval( $result['shipping_amount'] ),
959                'shipping_tax_amount'   => floatval( $result['shipping_tax_amount'] ),
960                'product_qty'           => intval( $result['product_qty'] ),
961                'variation_id'          => intval( $result['variation_id'] ),
962                'product_id'            => intval( $result['product_id'] ),
963                'customer_id'           => intval( $result['customer_id'] ),
964                'coupon_amount'         => floatval( $result['coupon_amount'] ),
965                'tax_amount'            => floatval( $result['tax_amount'] ),
966                'order_item_id'         => intval( $result['order_item_id'] ),
967                'order_id'              => intval( $result['order_id'] ),
968                'cogs_amount'           => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
969            );
970
971            $parsed_results[] = $product_data;
972        }
973
974        return $parsed_results;
975    }
976
977    /**
978     * Check if the order is a refund order.
979     *
980     * @param int $order_id The order ID.
981     * @return bool True if the order is a refund order, false otherwise.
982     */
983    private function is_refund_order( $order_id ) {
984        $order_stats_data = $this->get_order_stats_item( $order_id );
985
986        if ( ! $order_stats_data || empty( $order_stats_data['parent_id'] ) ) {
987            return false;
988        }
989
990        $parent_id               = $order_stats_data['parent_id'];
991        $parent_order_stats_data = $this->get_order_stats_item( $parent_id );
992
993        if ( ! $parent_order_stats_data || empty( $parent_order_stats_data['status'] ) ) {
994            return false;
995        }
996
997        // OrderInternalStatus is unavailable before WooCommerce 9.5.
998        return 'wc-refunded' === $parent_order_stats_data['status'];
999    }
1000
1001    /**
1002     * Get order coupon lookup data.
1003     *
1004     * @param mixed $order The order ID or the WC_Order object.
1005     * @return array|bool The order coupon data or false if no data exists.
1006     */
1007    protected function get_order_coupon_data( $order ) {
1008        if ( is_numeric( $order ) ) {
1009            $order_id = $order;
1010            $order    = wc_get_order( $order );
1011        } elseif ( $order instanceof WC_Abstract_Order ) {
1012            $order_id = $order->get_id();
1013        } else {
1014            return false;
1015        }
1016
1017        // If the order does not exist, check if coupon lookup data exists in the database.
1018        if ( ! $order ) {
1019            return $this->get_order_coupon_data_from_db( $order_id );
1020        }
1021
1022        // Get the order coupon data from the order object.
1023        $order_coupons = $order->get_coupons();
1024
1025        $results = array();
1026        foreach ( $order_coupons as $coupon ) {
1027            $results[] = array(
1028                'order_id'        => $order_id,
1029                'coupon_id'       => CouponsDataStore::get_coupon_id( $coupon ),
1030                'discount_amount' => $coupon->get_discount(),
1031                'date_created'    => self::datetime_to_object( $order->get_date_created() ),
1032                'coupon_code'     => $coupon->get_code(),
1033            );
1034        }
1035
1036        return $results;
1037    }
1038
1039    /**
1040     * Get order coupon lookup data from database.
1041     *
1042     * @param int $order_id The order ID.
1043     * @return array|bool The order coupon data or false if no data exists.
1044     */
1045    protected function get_order_coupon_data_from_db( $order_id ) {
1046        $results = $this->get_order_lookup_data_from_db( 'wc_order_coupon_lookup', $order_id );
1047
1048        if ( empty( $results ) ) {
1049            return false;
1050        }
1051
1052        $parsed_results = array();
1053        foreach ( $results as $result ) {
1054            $result_data                = array(
1055                'date_created'    => self::datetime_to_object( $result['date_created'] ),
1056                'discount_amount' => floatval( $result['discount_amount'] ),
1057                'order_id'        => intval( $result['order_id'] ),
1058                'coupon_id'       => intval( $result['coupon_id'] ),
1059            );
1060            $coupon                     = new WC_Coupon( absint( $result['coupon_id'] ) );
1061            $result_data['coupon_code'] = $coupon->get_code();
1062            $parsed_results[]           = $result_data;
1063        }
1064
1065        return $parsed_results;
1066    }
1067
1068    /**
1069     * Get order tax lookup data.
1070     *
1071     * @param mixed $order The order ID or the WC_Order object.
1072     * @return array|bool The order tax data or false if no data exists.
1073     */
1074    protected function get_order_tax_data( $order ) {
1075        if ( is_numeric( $order ) ) {
1076            $order_id = $order;
1077            $order    = wc_get_order( $order );
1078        } elseif ( $order instanceof WC_Abstract_Order ) {
1079            $order_id = $order->get_id();
1080        } else {
1081            return false;
1082        }
1083
1084        // If the order does not exist, check if tax lookup data exists in the database.
1085        if ( ! $order ) {
1086            return $this->get_order_tax_data_from_db( $order_id );
1087        }
1088
1089        // Get the order tax data from the order object.
1090        $order_taxes = $order->get_taxes();
1091
1092        $results = array();
1093        foreach ( $order_taxes as $tax ) {
1094            $order_tax    = (float) $tax->get_tax_total();
1095            $shipping_tax = (float) $tax->get_shipping_tax_total();
1096            $results[]    = array(
1097                'order_id'      => $order_id,
1098                'tax_rate_id'   => $tax->get_rate_id(),
1099                'order_tax'     => $order_tax,
1100                'shipping_tax'  => $shipping_tax,
1101                'total_tax'     => $order_tax + $shipping_tax,
1102                'date_created'  => self::datetime_to_object( $order->get_date_created() ),
1103                'tax_rate_code' => $tax->get_rate_code(),
1104            );
1105        }
1106
1107        return $results;
1108    }
1109
1110    /**
1111     * Get order tax lookup data from database.
1112     *
1113     * @param int $order_id The order ID.
1114     * @return array|bool The order tax data or false if no data exists.
1115     */
1116    protected function get_order_tax_data_from_db( $order_id ) {
1117        $results = $this->get_order_lookup_data_from_db( 'wc_order_tax_lookup', $order_id );
1118
1119        if ( empty( $results ) ) {
1120            return false;
1121        }
1122
1123        $parsed_results = array();
1124        foreach ( $results as $result ) {
1125            $result_data      = array(
1126                'date_created'  => self::datetime_to_object( $result['date_created'] ),
1127                'order_tax'     => floatval( $result['order_tax'] ),
1128                'total_tax'     => floatval( $result['total_tax'] ),
1129                'shipping_tax'  => floatval( $result['shipping_tax'] ),
1130                'order_id'      => intval( $result['order_id'] ),
1131                'tax_rate_id'   => intval( $result['tax_rate_id'] ),
1132                'tax_rate_code' => WC_Tax::get_rate_code( $result['tax_rate_id'] ) ?? '',
1133            );
1134            $parsed_results[] = $result_data;
1135        }
1136
1137        return $parsed_results;
1138    }
1139
1140    /**
1141     * Get order lookup data from database.
1142     *
1143     * @param string $table_name The name of the table.
1144     * @param int    $order_id The order ID.
1145     * @return array|bool The order lookup data or false if no data exists.
1146     */
1147    protected function get_order_lookup_data_from_db( $table_name, $order_id ) {
1148        global $wpdb;
1149
1150        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1151        $query = $wpdb->prepare(
1152            "SELECT * FROM {$wpdb->prefix}{$table_name} WHERE order_id = %d",
1153            $order_id
1154        );
1155        // phpcs:enable
1156
1157        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1158        $results = $wpdb->get_results( $query, ARRAY_A );
1159
1160        if ( empty( $results ) ) {
1161            return false;
1162        }
1163
1164        return $results;
1165    }
1166
1167    /**
1168     * Get the order stats data from the database.
1169     *
1170     * @param int $order_id The order ID.
1171     * @return array|bool The order stats data or false if the order stats item does not exist.
1172     */
1173    private function get_order_stats_data_from_db( $order_id ) {
1174        $order_stats_data = $this->get_order_stats_item( $order_id );
1175
1176        if ( ! $order_stats_data ) {
1177            return false;
1178        }
1179
1180        // Convert date strings to datetime objects.
1181        $order_stats_data['date_created']   = self::datetime_to_object( $order_stats_data['date_created'] );
1182        $order_stats_data['date_completed'] = self::datetime_to_object( $order_stats_data['date_completed'] );
1183        $order_stats_data['date_paid']      = self::datetime_to_object( $order_stats_data['date_paid'] );
1184
1185        return $order_stats_data;
1186    }
1187
1188    /**
1189     * Perform an order status discrepancy check between the order object and the item in the wc_order_stats table.
1190     *
1191     * @param WC_Order $order WC_Order object.
1192     * @param array    $order_stats_item The order stats item.
1193     *
1194     * @return void
1195     */
1196    private function do_order_status_discrepancy_check( $order, $order_stats_item = array() ) {
1197        if ( ! $order instanceof WC_Abstract_Order ) {
1198            return;
1199        }
1200
1201        $order_id = $order->get_id();
1202
1203        // If the order_stats_item is empty, then fetch it from the wc_order_stats table.
1204        if ( empty( $order_stats_item ) ) {
1205            $order_stats_item = $this->get_order_stats_data_from_db( $order_id );
1206        }
1207
1208        // Check for discrepancy in the order status. Happens in old orders that were not updated and hence the OrderStatsFixer did not run.
1209        $normalized_order_status = self::normalize_order_status( $order->get_status() );
1210        if ( $order_stats_item && $normalized_order_status !== $order_stats_item['status'] ) {
1211            /**
1212             * Trigger the action to fix the order stats. The OrderStatusFixer should be hooked to this action.
1213             *
1214             * @param int $order_id The order ID.
1215             */
1216            do_action( 'woocommerce_analytics_incorrect_order_status_detected', $order_id );
1217        }
1218    }
1219
1220    /**
1221     * Maps an order status to the value used in the database.
1222     *
1223     * @param string $status Order status.
1224     * @return string
1225     */
1226    protected static function normalize_order_status( $status ) {
1227        return WooCommerce_HPOS_Orders::get_wc_order_status_with_prefix( str_replace( 'wc-', '', $status ) );
1228    }
1229
1230    /**
1231     * Convert a WooCommerce datetime to an object for encoding.
1232     *
1233     * @param WC_DateTime|mixed $wc_datetime The datetime object.
1234     * @return object|null
1235     */
1236    protected static function datetime_to_object( $wc_datetime ) {
1237        if ( is_string( $wc_datetime ) ) {
1238            $wc_datetime = new WC_DateTime( $wc_datetime, self::get_site_datetimezone() );
1239        }
1240
1241        if ( is_a( $wc_datetime, 'WC_DateTime' ) ) {
1242            $wc_datetime->setTimezone( self::get_site_datetimezone() );
1243            $date_properties = (array) $wc_datetime;
1244
1245            // Remove protected properties, whose NUL-prefixed names cannot be processed by the receiver.
1246            foreach ( array_keys( $date_properties ) as $property_name ) {
1247                if ( false !== strpos( $property_name, "\0" ) ) {
1248                    unset( $date_properties[ $property_name ] );
1249                }
1250            }
1251
1252            return (object) $date_properties;
1253        }
1254    }
1255
1256    /**
1257     * Convert seconds to an ISO 8601 timezone offset.
1258     *
1259     * @param int|float $offset_seconds The timezone offset in seconds.
1260     * @return string The ISO 8601 timezone offset.
1261     */
1262    protected static function format_utc_offset( $offset_seconds ) {
1263        $hours   = intval( abs( $offset_seconds ) / HOUR_IN_SECONDS );
1264        $minutes = intval( ( abs( $offset_seconds ) % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
1265        $sign    = $offset_seconds >= 0 ? '+' : '-';
1266
1267        return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
1268    }
1269
1270    /**
1271     * Get the site timezone as a fixed offset.
1272     *
1273     * @return DateTimeZone The site timezone.
1274     */
1275    protected static function get_site_datetimezone() {
1276        return new DateTimeZone( self::format_utc_offset( wc_timezone_offset() ) );
1277    }
1278}