PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 All 504 releases
jetpack / jetpack_vendor / automattic / jetpack-sync / src / modules / class-woocommerce-analytics.php

class-woocommerce-analytics.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at jetpack_vendor/automattic/jetpack-sync/src/modules/class-woocommerce-analytics.php

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