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