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