Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.60% covered (danger)
28.60%
151 / 528
37.74% covered (danger)
37.74%
20 / 53
CRAP
0.00% covered (danger)
0.00%
0 / 1
WooCommerce_Analytics
28.71% covered (danger)
28.71%
151 / 526
37.74% covered (danger)
37.74%
20 / 53
11144.72
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
410.45
 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
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_order_product_data
12.24% covered (danger)
12.24%
6 / 49
0.00% covered (danger)
0.00%
0 / 1
77.58
 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
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            'date_paid'          => self::datetime_to_object( $order->get_date_paid() ),
698            'date_completed'     => self::datetime_to_object( $order->get_date_completed() ),
699            'num_items_sold'     => self::get_num_items_sold( $order ),
700            'total_sales'        => $order->get_total(),
701            'tax_total'          => $order->get_total_tax(),
702            'total_fees'         => $order->get_total_fees(),
703            'total_fees_tax'     => self::get_total_fees_tax( $order ),
704            'shipping_total'     => $order->get_shipping_total(),
705            'shipping_tax'       => $order->get_shipping_tax(),
706            'discount_total'     => $order->get_discount_total(),
707            'discount_tax'       => $order->get_discount_tax(),
708            'net_total'          => self::get_net_total( $order ),
709            'returning_customer' => $order->is_returning_customer(),
710            'status'             => self::normalize_order_status( $order->get_status() ),
711            'customer_id'        => $order->get_report_customer_id(),
712            'fulfillment_status' => $order_fulfillment_status,
713        );
714
715        // Mirrors the refund block of WooCommerce's Orders\Stats\DataStore::update().
716        if ( 'shop_order_refund' === $order->get_type() ) {
717            $parent_order = wc_get_order( $order->get_parent_id() );
718            // Refunds attach to the original order. Skip if the parent is another refund.
719            if ( $parent_order && ! $parent_order instanceof WC_Order_Refund ) {
720                $order_stats_data['parent_id'] = $parent_order->get_id();
721                // Unlike core, keep the refund's own status: the WooCommerce Analytics plugin writes it back to core's row.
722
723                $refund_type               = $order->get_meta( '_refund_type' );
724                $uses_new_full_refund_data = self::uses_new_full_refund_data();
725                $use_parent_refund_amounts = $uses_new_full_refund_data && (
726                    'full' === $refund_type
727                    || self::should_split_full_refund_using_parent_order( $order, $parent_order )
728                );
729                if ( $use_parent_refund_amounts ) {
730                    $order_stats_data['num_items_sold'] = -1 * self::get_num_items_sold( $parent_order );
731                    $order_stats_data['tax_total']      = -1 * $parent_order->get_total_tax();
732                    $order_stats_data['net_total']      = -1 * self::get_net_total( $parent_order );
733                    $order_stats_data['shipping_total'] = -1 * (float) $parent_order->get_shipping_total();
734
735                    // Subtract what earlier refunds recorded, so this row only holds the remainder.
736                    foreach ( $parent_order->get_refunds() as $prior_refund ) {
737                        if ( $prior_refund->get_id() === $order->get_id() ) {
738                            continue;
739                        }
740                        $order_stats_data['num_items_sold'] -= self::get_num_items_sold( $prior_refund );
741                        $order_stats_data['tax_total']      -= (float) $prior_refund->get_total_tax();
742                        $order_stats_data['net_total']      -= self::get_net_total( $prior_refund );
743                        $order_stats_data['shipping_total'] -= (float) $prior_refund->get_shipping_total();
744                    }
745                }
746            }
747            // Refunds have no paid or completed date; backfill each from date_created, but only where the parent has it.
748            // Follow core's stored row when there is one: rows an older WooCommerce wrote keep their dates, and the checksum compares both.
749            $order_stats_item ??= $this->get_order_stats_item( $order->get_id() );
750            if ( $order_stats_item ) {
751                $has_date_completed = null !== $order_stats_item['date_completed'];
752                $has_date_paid      = null !== $order_stats_item['date_paid'];
753            } else {
754                $dates_follow_parent = $parent_order instanceof WC_Order && self::refund_dates_follow_parent();
755                $has_date_completed  = ! $dates_follow_parent || $parent_order->get_date_completed();
756                $has_date_paid       = ! $dates_follow_parent || $parent_order->get_date_paid();
757            }
758            $order_stats_data['date_completed'] = $has_date_completed ? $order_stats_data['date_created'] : null;
759            $order_stats_data['date_paid']      = $has_date_paid ? $order_stats_data['date_created'] : null;
760        }
761
762        return $order_stats_data;
763    }
764
765    /**
766     * Whether WooCommerce leaves a refund's paid and completed dates empty when its parent order has none.
767     *
768     * @return bool
769     */
770    private static function refund_dates_follow_parent() {
771        return defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '11.2', '>=' );
772    }
773
774    /**
775     * Whether this refund is a single lump-sum refund for the full order. Copied from WooCommerce's Orders\Stats\DataStore, where it is protected.
776     *
777     * @param WC_Abstract_Order $refund       Refund order.
778     * @param WC_Abstract_Order $parent_order Parent order (not a refund).
779     * @return bool
780     */
781    private static function should_split_full_refund_using_parent_order( $refund, $parent_order ) {
782        // The parent must be the original order, not another refund.
783        if ( ! $parent_order instanceof WC_Order || 'shop_order_refund' === $parent_order->get_type() ) {
784            return false;
785        }
786
787        if ( self::get_num_items_sold( $refund ) > 0 ) {
788            return false;
789        }
790
791        $parent_refunds = $parent_order->get_refunds();
792        if ( 1 !== count( $parent_refunds ) ) {
793            return false;
794        }
795
796        $refund_total = wc_format_decimal( abs( (float) $refund->get_total() ) );
797        $order_total  = wc_format_decimal( (float) $parent_order->get_total() );
798
799        return $refund_total === $order_total;
800    }
801
802    /**
803     * Check whether WooCommerce stores full refunds using the new data format.
804     *
805     * @return bool Whether the new full-refund data format is in use.
806     */
807    private static function uses_new_full_refund_data() {
808        if ( ! is_callable( array( OrderUtil::class, 'uses_new_full_refund_data' ) ) ) {
809            return false;
810        }
811
812        // @phan-suppress-next-line PhanUndeclaredStaticMethod -- Guarded by is_callable(); absent from the older WooCommerce stubs used by the "old Woo" Phan job.
813        return OrderUtil::uses_new_full_refund_data();
814    }
815
816    /**
817     * Calculation methods.
818     */
819
820    /**
821     * Get number of items sold among all orders.
822     *
823     * @param WC_Abstract_Order $order Order or refund.
824     * @return int
825     */
826    protected static function get_num_items_sold( $order ) {
827        $num_items = 0;
828
829        $line_items = $order->get_items( 'line_item' );
830        foreach ( $line_items as $line_item ) {
831            $num_items += $line_item->get_quantity();
832        }
833
834        return $num_items;
835    }
836
837    /**
838     * Get the net amount from an order without shipping, tax, or refunds.
839     *
840     * @param WC_Abstract_Order $order Order or refund.
841     * @return float
842     */
843    protected static function get_net_total( $order ) {
844        $net_total = floatval( $order->get_total() ) - floatval( $order->get_total_tax() ) - floatval( $order->get_shipping_total() );
845        return $net_total;
846    }
847
848    /**
849     * Get the total fees tax from an order.
850     *
851     * @param WC_Order $order WC_Order object.
852     * @return float
853     */
854    protected static function get_total_fees_tax( $order ) {
855        $total_fees_tax = array_sum(
856            array_map(
857                function ( $item ) {
858                    return $item->get_total_tax();
859                },
860                array_values( $order->get_items( 'fee' ) )
861            )
862        );
863
864        return $total_fees_tax;
865    }
866
867    /**
868     * Get the order stats row for a given order ID.
869     *
870     * @param int $order_id The order ID.
871     * @return array|null|void Database query result in format specified by $output or null on failure.
872     */
873    private function get_order_stats_item( $order_id ) {
874        global $wpdb;
875
876        $query = $wpdb->prepare(
877            "SELECT * FROM {$this->table()} WHERE order_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
878            $order_id
879        );
880
881        return $wpdb->get_row( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
882    }
883
884    /**
885     * Get the order stats rows for a given order IDs.
886     *
887     * @param array $order_ids The order IDs.
888     * @return array|null Database query result in format specified by $output or null on failure.
889     */
890    private function get_order_stats_items( $order_ids ) {
891        global $wpdb;
892
893        $placeholders = implode( ',', array_fill( 0, count( $order_ids ), '%d' ) );
894        $query        = $wpdb->prepare(
895            "SELECT * FROM {$this->table()} WHERE order_id IN ( $placeholders )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
896            $order_ids
897        );
898
899        return $wpdb->get_results( $query, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
900    }
901
902    /**
903     * Get the order as WooCommerce Analytics' order class, which adds the report methods used here.
904     *
905     * WooCommerce swaps that class in only while Analytics is enabled, and an object cache can outlive the switch.
906     *
907     * @param WC_Abstract_Order|false $order The order object.
908     * @return WC_Abstract_Order|false The order with report methods, or false if it cannot have them.
909     */
910    private static function get_analytics_order( $order ) {
911        if ( ! $order || method_exists( $order, 'get_report_customer_id' ) ) {
912            return $order;
913        }
914
915        // Mirrors the exact-class check in WooCommerce's order_class_name filters, which leave subclasses alone.
916        $analytics_class = self::$analytics_order_classes[ get_class( $order ) ] ?? null;
917        if ( null === $analytics_class || ! class_exists( $analytics_class ) ) {
918            return false;
919        }
920
921        return new $analytics_class( $order->get_id() );
922    }
923
924    /**
925     * Query orders, loading them as WooCommerce Analytics' order classes even while Analytics is disabled.
926     *
927     * @param array $query_args Arguments for wc_get_orders().
928     * @return WC_Order[]|\stdClass What wc_get_orders() returns.
929     */
930    private static function get_analytics_orders( $query_args ) {
931        $added_callbacks = array();
932        foreach ( self::$analytics_order_classes as $analytics_class ) {
933            $callback = array( $analytics_class, 'order_class_name' );
934            // Only add, and later remove, what WooCommerce has not, or its own filter goes with it.
935            if ( class_exists( $analytics_class ) && false === has_filter( 'woocommerce_order_class', $callback ) ) {
936                add_filter( 'woocommerce_order_class', $callback, 10, 3 );
937                $added_callbacks[] = $callback;
938            }
939        }
940
941        try {
942            return wc_get_orders( $query_args );
943        } finally {
944            foreach ( $added_callbacks as $callback ) {
945                remove_filter( 'woocommerce_order_class', $callback, 10 );
946            }
947        }
948    }
949
950    /**
951     * Check if the COGS feature is enabled.
952     *
953     * @return bool True if the COGS feature is enabled, false otherwise.
954     */
955    private function is_cogs_enabled() {
956        return FeaturesUtil::feature_is_enabled( 'cost_of_goods_sold' );
957    }
958
959    /**
960     * Get order product lookup data.
961     *
962     * @param mixed $order The order ID or the WC_Order object.
963     * @return array|bool The order product data or false if no data exists.
964     */
965    protected function get_order_product_data( $order ) {
966        if ( is_numeric( $order ) ) {
967            $order_id = $order;
968            $order    = wc_get_order( $order );
969        } elseif ( $order instanceof WC_Abstract_Order ) {
970            $order_id = $order->get_id();
971        } else {
972            return false;
973        }
974
975        // If the order does not exist or cannot have report methods, read its lookup rows instead.
976        $order = self::get_analytics_order( $order );
977        if ( ! $order ) {
978            return $this->get_order_product_data_from_db( $order_id );
979        }
980
981        // Get the order product data from the order object.
982        $order_products = $order->get_items( 'line_item' );
983
984        if ( empty( $order_products ) ) {
985            // Not a common use case, but there could be a case where this returns empty.
986            return $this->get_order_product_data_from_db( $order_id );
987        }
988
989        $is_refund_order = $this->is_refund_order( $order_id );
990        $round_tax       = 'no' === get_option( 'woocommerce_tax_round_at_subtotal' );
991        $decimals        = wc_get_price_decimals();
992
993        $results = array();
994        foreach ( $order_products as $order_product ) {
995            $shipping_amount     = $order->get_item_shipping_amount( $order_product );
996            $shipping_tax_amount = $order->get_item_shipping_tax_amount( $order_product );
997            $coupon_amount       = $order->get_item_coupon_amount( $order_product );
998            // Tax amount.
999            $tax_amount  = 0;
1000            $order_taxes = $order->get_taxes();
1001            $tax_data    = $order_product->get_taxes();
1002            foreach ( $order_taxes as $tax_item ) {
1003                $tax_item_id = $tax_item->get_rate_id();
1004                $tax_amount += isset( $tax_data['total'][ $tax_item_id ] ) ? (float) $tax_data['total'][ $tax_item_id ] : 0;
1005            }
1006
1007            $net_revenue = round( $order_product->get_total( 'edit' ), $decimals );
1008            if ( $round_tax ) {
1009                $tax_amount = round( $tax_amount, $decimals );
1010            }
1011
1012            $product_id  = $order_product->get_product_id();
1013            $cogs_amount = $this->get_order_product_cogs_value( $order_product );
1014
1015            $product_data = array(
1016                'order_id'              => $order_id,
1017                'order_item_id'         => $order_product->get_id(),
1018                'product_id'            => $product_id,
1019                'variation_id'          => $order_product->get_variation_id(),
1020                'product_qty'           => $order_product->get_quantity(),
1021                'product_net_revenue'   => $net_revenue,
1022                'product_gross_revenue' => $net_revenue + $tax_amount + $shipping_amount + $shipping_tax_amount,
1023                'shipping_amount'       => $shipping_amount,
1024                'shipping_tax_amount'   => $shipping_tax_amount,
1025                'coupon_amount'         => $coupon_amount,
1026                'tax_amount'            => $tax_amount,
1027                'customer_id'           => $order->get_report_customer_id(),
1028                'date_created'          => self::datetime_to_object( $order->get_date_created() ),
1029                'cogs_amount'           => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
1030            );
1031
1032            $results[] = $product_data;
1033        }
1034
1035        return $results;
1036    }
1037
1038    /**
1039     * Get COGS value for an order product.
1040     *
1041     * @param object|false $order_product The order product object, or false if it no longer exists.
1042     * @return float|null The COGS amount or null if not available.
1043     */
1044    private function get_order_product_cogs_value( $order_product ) {
1045        if ( ! is_object( $order_product ) || ! method_exists( $order_product, 'get_cogs_value' ) || ! $this->is_cogs_enabled() ) {
1046            return null;
1047        }
1048
1049        $cogs_amount = $order_product->get_cogs_value();
1050
1051        // Only fallback to product's COGS value if order product's COGS is null (not set).
1052        if ( null === $cogs_amount ) {
1053            $product_id = $order_product->get_product_id();
1054            $product    = wc_get_product( $product_id );
1055
1056            if ( $product && method_exists( $product, 'get_cogs_value' ) ) {
1057                $product_cogs_value = $product->get_cogs_value();
1058                if ( null !== $product_cogs_value ) {
1059                    $cogs_amount = $product_cogs_value;
1060                }
1061            }
1062        }
1063
1064        return $cogs_amount;
1065    }
1066
1067    /**
1068     * Get order product lookup data from database.
1069     *
1070     * @param int $order_id The order ID.
1071     * @return array|bool The order product data or false if no data exists.
1072     */
1073    protected function get_order_product_data_from_db( $order_id ) {
1074        $results = $this->get_order_lookup_data_from_db( 'wc_order_product_lookup', $order_id );
1075
1076        if ( empty( $results ) ) {
1077            return false;
1078        }
1079
1080        $is_refund_order = $this->is_refund_order( $order_id );
1081
1082        $parsed_results = array();
1083        foreach ( $results as $result ) {
1084            $order_item  = WC_Order_Factory::get_order_item( absint( $result['order_item_id'] ) );
1085            $cogs_amount = $this->get_order_product_cogs_value( $order_item );
1086
1087            $product_data = array(
1088                'date_created'          => self::datetime_to_object( $result['date_created'] ),
1089                'product_net_revenue'   => floatval( $result['product_net_revenue'] ),
1090                'product_gross_revenue' => floatval( $result['product_gross_revenue'] ),
1091                'shipping_amount'       => floatval( $result['shipping_amount'] ),
1092                'shipping_tax_amount'   => floatval( $result['shipping_tax_amount'] ),
1093                'product_qty'           => intval( $result['product_qty'] ),
1094                'variation_id'          => intval( $result['variation_id'] ),
1095                'product_id'            => intval( $result['product_id'] ),
1096                'customer_id'           => intval( $result['customer_id'] ),
1097                'coupon_amount'         => floatval( $result['coupon_amount'] ),
1098                'tax_amount'            => floatval( $result['tax_amount'] ),
1099                'order_item_id'         => intval( $result['order_item_id'] ),
1100                'order_id'              => intval( $result['order_id'] ),
1101                'cogs_amount'           => $is_refund_order ? -abs( $cogs_amount ) : $cogs_amount,
1102            );
1103
1104            $parsed_results[] = $product_data;
1105        }
1106
1107        return $parsed_results;
1108    }
1109
1110    /**
1111     * Check if the order is a refund order.
1112     *
1113     * @param int $order_id The order ID.
1114     * @return bool True if the order is a refund order, false otherwise.
1115     */
1116    private function is_refund_order( $order_id ) {
1117        $order_stats_data = $this->get_order_stats_item( $order_id );
1118
1119        if ( ! $order_stats_data || empty( $order_stats_data['parent_id'] ) ) {
1120            return false;
1121        }
1122
1123        $parent_id               = $order_stats_data['parent_id'];
1124        $parent_order_stats_data = $this->get_order_stats_item( $parent_id );
1125
1126        if ( ! $parent_order_stats_data || empty( $parent_order_stats_data['status'] ) ) {
1127            return false;
1128        }
1129
1130        // OrderInternalStatus is unavailable before WooCommerce 9.5.
1131        return 'wc-refunded' === $parent_order_stats_data['status'];
1132    }
1133
1134    /**
1135     * Get order coupon lookup data.
1136     *
1137     * @param mixed $order The order ID or the WC_Order object.
1138     * @return array|bool The order coupon data or false if no data exists.
1139     */
1140    protected function get_order_coupon_data( $order ) {
1141        if ( is_numeric( $order ) ) {
1142            $order_id = $order;
1143            $order    = wc_get_order( $order );
1144        } elseif ( $order instanceof WC_Abstract_Order ) {
1145            $order_id = $order->get_id();
1146        } else {
1147            return false;
1148        }
1149
1150        // If the order does not exist, check if coupon lookup data exists in the database.
1151        if ( ! $order ) {
1152            return $this->get_order_coupon_data_from_db( $order_id );
1153        }
1154
1155        // Get the order coupon data from the order object.
1156        $order_coupons = $order->get_coupons();
1157
1158        $results = array();
1159        foreach ( $order_coupons as $coupon ) {
1160            $results[] = array(
1161                'order_id'        => $order_id,
1162                'coupon_id'       => CouponsDataStore::get_coupon_id( $coupon ),
1163                'discount_amount' => $coupon->get_discount(),
1164                'date_created'    => self::datetime_to_object( $order->get_date_created() ),
1165                'coupon_code'     => $coupon->get_code(),
1166            );
1167        }
1168
1169        return $results;
1170    }
1171
1172    /**
1173     * Get order coupon lookup data from database.
1174     *
1175     * @param int $order_id The order ID.
1176     * @return array|bool The order coupon data or false if no data exists.
1177     */
1178    protected function get_order_coupon_data_from_db( $order_id ) {
1179        $results = $this->get_order_lookup_data_from_db( 'wc_order_coupon_lookup', $order_id );
1180
1181        if ( empty( $results ) ) {
1182            return false;
1183        }
1184
1185        $parsed_results = array();
1186        foreach ( $results as $result ) {
1187            $result_data                = array(
1188                'date_created'    => self::datetime_to_object( $result['date_created'] ),
1189                'discount_amount' => floatval( $result['discount_amount'] ),
1190                'order_id'        => intval( $result['order_id'] ),
1191                'coupon_id'       => intval( $result['coupon_id'] ),
1192            );
1193            $coupon                     = new WC_Coupon( absint( $result['coupon_id'] ) );
1194            $result_data['coupon_code'] = $coupon->get_code();
1195            $parsed_results[]           = $result_data;
1196        }
1197
1198        return $parsed_results;
1199    }
1200
1201    /**
1202     * Get order tax lookup data.
1203     *
1204     * @param mixed $order The order ID or the WC_Order object.
1205     * @return array|bool The order tax data or false if no data exists.
1206     */
1207    protected function get_order_tax_data( $order ) {
1208        if ( is_numeric( $order ) ) {
1209            $order_id = $order;
1210            $order    = wc_get_order( $order );
1211        } elseif ( $order instanceof WC_Abstract_Order ) {
1212            $order_id = $order->get_id();
1213        } else {
1214            return false;
1215        }
1216
1217        // If the order does not exist, check if tax lookup data exists in the database.
1218        if ( ! $order ) {
1219            return $this->get_order_tax_data_from_db( $order_id );
1220        }
1221
1222        // Get the order tax data from the order object.
1223        $order_taxes = $order->get_taxes();
1224
1225        $results = array();
1226        foreach ( $order_taxes as $tax ) {
1227            $order_tax    = (float) $tax->get_tax_total();
1228            $shipping_tax = (float) $tax->get_shipping_tax_total();
1229            $results[]    = array(
1230                'order_id'      => $order_id,
1231                'tax_rate_id'   => $tax->get_rate_id(),
1232                'order_tax'     => $order_tax,
1233                'shipping_tax'  => $shipping_tax,
1234                'total_tax'     => $order_tax + $shipping_tax,
1235                'date_created'  => self::datetime_to_object( $order->get_date_created() ),
1236                'tax_rate_code' => $tax->get_rate_code(),
1237            );
1238        }
1239
1240        return $results;
1241    }
1242
1243    /**
1244     * Get order tax lookup data from database.
1245     *
1246     * @param int $order_id The order ID.
1247     * @return array|bool The order tax data or false if no data exists.
1248     */
1249    protected function get_order_tax_data_from_db( $order_id ) {
1250        $results = $this->get_order_lookup_data_from_db( 'wc_order_tax_lookup', $order_id );
1251
1252        if ( empty( $results ) ) {
1253            return false;
1254        }
1255
1256        $parsed_results = array();
1257        foreach ( $results as $result ) {
1258            $result_data      = array(
1259                'date_created'  => self::datetime_to_object( $result['date_created'] ),
1260                'order_tax'     => floatval( $result['order_tax'] ),
1261                'total_tax'     => floatval( $result['total_tax'] ),
1262                'shipping_tax'  => floatval( $result['shipping_tax'] ),
1263                'order_id'      => intval( $result['order_id'] ),
1264                'tax_rate_id'   => intval( $result['tax_rate_id'] ),
1265                'tax_rate_code' => WC_Tax::get_rate_code( $result['tax_rate_id'] ) ?? '',
1266            );
1267            $parsed_results[] = $result_data;
1268        }
1269
1270        return $parsed_results;
1271    }
1272
1273    /**
1274     * Get order lookup data from database.
1275     *
1276     * @param string $table_name The name of the table.
1277     * @param int    $order_id The order ID.
1278     * @return array|bool The order lookup data or false if no data exists.
1279     */
1280    protected function get_order_lookup_data_from_db( $table_name, $order_id ) {
1281        global $wpdb;
1282
1283        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1284        $query = $wpdb->prepare(
1285            "SELECT * FROM {$wpdb->prefix}{$table_name} WHERE order_id = %d",
1286            $order_id
1287        );
1288        // phpcs:enable
1289
1290        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1291        $results = $wpdb->get_results( $query, ARRAY_A );
1292
1293        if ( empty( $results ) ) {
1294            return false;
1295        }
1296
1297        return $results;
1298    }
1299
1300    /**
1301     * Get the order stats data from the database.
1302     *
1303     * @param int $order_id The order ID.
1304     * @return array|bool The order stats data or false if the order stats item does not exist.
1305     */
1306    private function get_order_stats_data_from_db( $order_id ) {
1307        $order_stats_data = $this->get_order_stats_item( $order_id );
1308
1309        if ( ! $order_stats_data ) {
1310            return false;
1311        }
1312
1313        // Convert date strings to datetime objects.
1314        $order_stats_data['date_created']   = self::datetime_to_object( $order_stats_data['date_created'] );
1315        $order_stats_data['date_completed'] = self::datetime_to_object( $order_stats_data['date_completed'] );
1316        $order_stats_data['date_paid']      = self::datetime_to_object( $order_stats_data['date_paid'] );
1317
1318        return $order_stats_data;
1319    }
1320
1321    /**
1322     * Perform an order status discrepancy check between the order object and the item in the wc_order_stats table.
1323     *
1324     * @param WC_Order $order WC_Order object.
1325     * @param array    $order_stats_item The order stats item.
1326     *
1327     * @return void
1328     */
1329    private function do_order_status_discrepancy_check( $order, $order_stats_item = array() ) {
1330        if ( ! $order instanceof WC_Abstract_Order ) {
1331            return;
1332        }
1333
1334        $order_id = $order->get_id();
1335
1336        // If the order_stats_item is empty, then fetch it from the wc_order_stats table.
1337        if ( empty( $order_stats_item ) ) {
1338            $order_stats_item = $this->get_order_stats_data_from_db( $order_id );
1339        }
1340
1341        // Check for discrepancy in the order status. Happens in old orders that were not updated and hence the OrderStatsFixer did not run.
1342        $normalized_order_status = self::normalize_order_status( $order->get_status() );
1343        if ( $order_stats_item && $normalized_order_status !== $order_stats_item['status'] ) {
1344            /**
1345             * Trigger the action to fix the order stats. The OrderStatusFixer should be hooked to this action.
1346             *
1347             * @param int $order_id The order ID.
1348             */
1349            do_action( 'woocommerce_analytics_incorrect_order_status_detected', $order_id );
1350        }
1351    }
1352
1353    /**
1354     * Maps an order status to the value used in the database.
1355     *
1356     * @param string $status Order status.
1357     * @return string
1358     */
1359    protected static function normalize_order_status( $status ) {
1360        return WooCommerce_HPOS_Orders::get_wc_order_status_with_prefix( str_replace( 'wc-', '', $status ) );
1361    }
1362
1363    /**
1364     * Convert a WooCommerce datetime to an object for encoding.
1365     *
1366     * @param WC_DateTime|mixed $wc_datetime The datetime object.
1367     * @return object|null
1368     */
1369    protected static function datetime_to_object( $wc_datetime ) {
1370        if ( is_string( $wc_datetime ) ) {
1371            $wc_datetime = new WC_DateTime( $wc_datetime, self::get_site_datetimezone() );
1372        }
1373
1374        if ( is_a( $wc_datetime, 'WC_DateTime' ) ) {
1375            $wc_datetime->setTimezone( self::get_site_datetimezone() );
1376            $date_properties = (array) $wc_datetime;
1377
1378            // Remove protected properties, whose NUL-prefixed names cannot be processed by the receiver.
1379            foreach ( array_keys( $date_properties ) as $property_name ) {
1380                if ( false !== strpos( $property_name, "\0" ) ) {
1381                    unset( $date_properties[ $property_name ] );
1382                }
1383            }
1384
1385            return (object) $date_properties;
1386        }
1387    }
1388
1389    /**
1390     * Convert seconds to an ISO 8601 timezone offset.
1391     *
1392     * @param int|float $offset_seconds The timezone offset in seconds.
1393     * @return string The ISO 8601 timezone offset.
1394     */
1395    protected static function format_utc_offset( $offset_seconds ) {
1396        $hours   = intval( abs( $offset_seconds ) / HOUR_IN_SECONDS );
1397        $minutes = intval( ( abs( $offset_seconds ) % HOUR_IN_SECONDS ) / MINUTE_IN_SECONDS );
1398        $sign    = $offset_seconds >= 0 ? '+' : '-';
1399
1400        return sprintf( '%s%02d:%02d', $sign, $hours, $minutes );
1401    }
1402
1403    /**
1404     * Get the site timezone as a fixed offset.
1405     *
1406     * @return DateTimeZone The site timezone.
1407     */
1408    protected static function get_site_datetimezone() {
1409        return new DateTimeZone( self::format_utc_offset( wc_timezone_offset() ) );
1410    }
1411}