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